diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 4f42fb174..8511267f7 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -168,6 +168,7 @@ "@logtape/logtape": "^1.1.1", "@logtape/sentry": "^1.1.1", "@modelcontextprotocol/sdk": "catalog:", + "@sentry/api": "^0.248.0", "@sentry/core": "catalog:", "ai": "catalog:", "dotenv": "catalog:", diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 2309bef94..15cd190c7 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -3,6 +3,7 @@ import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import { SentryApiService } from "./client"; import { ConfigurationError } from "../errors"; +import { ApiPermissionError } from "./errors"; describe("getIssueUrl", () => { it("should work with sentry.io", () => { @@ -707,25 +708,32 @@ describe("listOrganizations", () => { const mockOrgsEu = [{ id: "2", slug: "org-eu", name: "Org EU" }]; let callCount = 0; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { + globalThis.fetch = vi.fn().mockImplementation((input: string | Request) => { callCount++; + const url = typeof input === "string" ? input : (input as Request).url; if (url.includes("/users/me/regions/")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockRegionsResponse), - }); + return Promise.resolve( + new Response(JSON.stringify(mockRegionsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); } if (url.includes("us.sentry.io")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockOrgsUs), - }); + return Promise.resolve( + new Response(JSON.stringify(mockOrgsUs), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); } if (url.includes("eu.sentry.io")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockOrgsEu), - }); + return Promise.resolve( + new Response(JSON.stringify(mockOrgsEu), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); } return Promise.reject(new Error("Unexpected URL")); }); @@ -750,15 +758,18 @@ describe("listOrganizations", () => { ]; let callCount = 0; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { + globalThis.fetch = vi.fn().mockImplementation((input: string | Request) => { callCount++; + const url = typeof input === "string" ? input : (input as Request).url; if (url.includes("/organizations/")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockOrgs), - }); + return Promise.resolve( + new Response(JSON.stringify(mockOrgs), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); } - return Promise.reject(new Error("Unexpected URL")); + return Promise.reject(new Error(`Unexpected URL: ${url}`)); }); const apiService = new SentryApiService({ @@ -771,11 +782,6 @@ describe("listOrganizations", () => { expect(callCount).toBe(1); // Only 1 org call, no regions call expect(result).toHaveLength(2); expect(result).toEqual(mockOrgs); - // Verify that regions endpoint was not called - expect(globalThis.fetch).not.toHaveBeenCalledWith( - expect.stringContaining("/users/me/regions/"), - expect.any(Object), - ); }); it("should fall back to direct organizations endpoint when regions endpoint returns 404 on SaaS", async () => { @@ -784,7 +790,8 @@ describe("listOrganizations", () => { { id: "2", slug: "org-2", name: "Organization 2" }, ]; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { + globalThis.fetch = vi.fn().mockImplementation((input: string | Request) => { + const url = typeof input === "string" ? input : (input as Request).url; if (url.includes("/users/me/regions/")) { return Promise.resolve({ ok: false, @@ -794,10 +801,12 @@ describe("listOrganizations", () => { }); } if (url.includes("/organizations/")) { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve(mockOrgs), - }); + return Promise.resolve( + new Response(JSON.stringify(mockOrgs), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); } return Promise.reject(new Error("Unexpected URL")); }); @@ -811,16 +820,6 @@ describe("listOrganizations", () => { expect(result).toHaveLength(2); expect(result).toEqual(mockOrgs); - - // Verify it tried regions first, then fell back to organizations - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("/users/me/regions/"), - expect.any(Object), - ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("/organizations/"), - expect.any(Object), - ); }); }); @@ -1184,21 +1183,45 @@ describe("API query builders", () => { }); describe("searchEvents integration", () => { + /** Helper: extract the URL string from whatever `fetch` received. */ + function extractFetchUrl(call: unknown[]): string { + const input = call[0]; + if (typeof input === "string") return input; + if (input instanceof Request) return input.url; + return String(input); + } + + /** Build a mock that returns a proper Response for SDK calls. */ + function makeSdkMock(body: unknown = { data: [] }) { + return vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + } + + function makeSdkErrorMock(body: unknown, status: number, statusText = "") { + return vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + statusText, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + } + it("should route errors dataset to Discover API builder", async () => { const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); - // Mock the API response - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve({ data: [] }), - }); + globalThis.fetch = makeSdkMock(); await apiService.searchEvents({ organizationSlug: "test-org", @@ -1208,19 +1231,12 @@ describe("API query builders", () => { sort: "-count()", }); - // Verify the URL contains correct parameters - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("dataset=errors"), - expect.any(Object), - ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("sort=-count%28%29"), - expect.any(Object), - ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("referrer=api.mcp.search-events"), - expect.any(Object), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); + expect(url).toContain("dataset=errors"); + expect(url).toContain("sort=-count"); + expect(url).toContain("referrer=api.mcp.search-events"); }); it("should route spans dataset to EAP API builder with sampling", async () => { @@ -1229,15 +1245,7 @@ describe("API query builders", () => { accessToken: "test-token", }); - // Mock the API response - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve({ data: [] }), - }); + globalThis.fetch = makeSdkMock(); await apiService.searchEvents({ organizationSlug: "test-org", @@ -1246,19 +1254,12 @@ describe("API query builders", () => { dataset: "spans", }); - // Verify the URL contains correct parameters - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("dataset=spans"), - expect.any(Object), - ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("sampling=NORMAL"), - expect.any(Object), - ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("referrer=api.mcp.search-events"), - expect.any(Object), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); + expect(url).toContain("dataset=spans"); + expect(url).toContain("sampling=NORMAL"); + expect(url).toContain("referrer=api.mcp.search-events"); }); it("should normalize metrics dataset to tracemetrics for Discover queries", async () => { @@ -1267,14 +1268,7 @@ describe("API query builders", () => { accessToken: "test-token", }); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve({ data: [] }), - }); + globalThis.fetch = makeSdkMock(); await apiService.searchEvents({ organizationSlug: "test-org", @@ -1287,15 +1281,14 @@ describe("API query builders", () => { sort: "-p95(value,http.request.duration,distribution,millisecond)", }); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining("dataset=tracemetrics"), - expect.any(Object), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining( - "sort=-p95%28value%2Chttp.request.duration%2Cdistribution%2Cmillisecond%29", - ), - expect.any(Object), + expect(url).toContain("dataset=tracemetrics"); + // The sort param may encode parentheses as %28/%29 or leave them literal + // depending on the URL serializer (URLSearchParams vs SDK) + expect(decodeURIComponent(url)).toContain( + "sort=-p95(value,http.request.duration,distribution,millisecond)", ); }); @@ -1305,14 +1298,7 @@ describe("API query builders", () => { accessToken: "test-token", }); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve({ data: [] }), - }); + globalThis.fetch = makeSdkMock({ data: [] }); await apiService.searchReplays({ organizationSlug: "test-org", @@ -1323,12 +1309,226 @@ describe("API query builders", () => { limit: 25, }); - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.stringContaining( - "/api/0/organizations/test-org/replays/?query=count_errors%3A%3E0&per_page=25&sort=-count_errors&environment=production&environment=staging&statsPeriod=24h", - ), - expect.any(Object), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], + ); + expect(url).toContain("/api/0/organizations/test-org/replays/"); + expect(url).toContain("query=count_errors%3A%3E0"); + expect(url).toContain("sort=-count_errors"); + + const parsedUrl = new URL(url); + expect(parsedUrl.searchParams.getAll("environment")).toEqual([ + "production", + "staging", + ]); + expect(parsedUrl.searchParams.get("statsPeriod")).toBe("24h"); + }); + + it("should forward a project slug verbatim (not coerce to NaN)", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkMock({ data: [] }); + + await apiService.searchReplays({ + organizationSlug: "test-org", + projectId: "my-project-slug", + statsPeriod: "24h", + }); + + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); + // The replays `project` param accepts IDs or slugs; the slug must pass + // through unchanged (Number(slug) would be NaN). + expect(new URL(url).searchParams.getAll("project")).toEqual([ + "my-project-slug", + ]); + }); + + it("getFlamegraph forwards a project slug verbatim (not NaN)", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + globalThis.fetch = makeSdkMock({ data: {} }); + // Response won't parse; we only assert the outgoing request's project param + // (captured at fetch time, before parsing). + await apiService + .getFlamegraph({ + organizationSlug: "test-org", + projectId: "my-project-slug", + transactionName: "GET /foo", + }) + .catch(() => {}); + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], + ); + expect(new URL(url).searchParams.getAll("project")).toEqual([ + "my-project-slug", + ]); + }); + + it("getProfileChunk forwards a project slug verbatim (not NaN)", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + globalThis.fetch = makeSdkMock({ data: {} }); + await apiService + .getProfileChunk({ + organizationSlug: "test-org", + profilerId: "prof-1", + projectId: "my-project-slug", + start: "2024-01-01T00:00:00Z", + end: "2024-01-01T01:00:00Z", + }) + .catch(() => {}); + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], + ); + expect(new URL(url).searchParams.get("project")).toBe("my-project-slug"); + }); + + it("should reject conflicting replay time parameters", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkMock({ data: [] }); + + await expect( + apiService.searchReplays({ + organizationSlug: "test-org", + statsPeriod: "24h", + start: "2025-01-01T00:00:00Z", + end: "2025-01-02T00:00:00Z", + }), + ).rejects.toThrow("Cannot use both statsPeriod and start/end parameters"); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("should require paired replay start and end parameters", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkMock({ data: [] }); + + await expect( + apiService.searchReplays({ + organizationSlug: "test-org", + start: "2025-01-01T00:00:00Z", + }), + ).rejects.toThrow( + "Both start and end parameters must be provided together", + ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it("should prefer statsPeriod over absolute time params for issue events", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkMock([]); + + await apiService.listEventsForIssue({ + organizationSlug: "test-org", + issueId: "123", + query: "environment:production", + limit: 25, + sort: "-timestamp", + statsPeriod: "24h", + start: "2025-01-01T00:00:00Z", + end: "2025-01-02T00:00:00Z", + }); + + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], + ); + const parsedUrl = new URL(url); + + expect(parsedUrl.searchParams.get("query")).toBe( + "environment:production", + ); + expect(parsedUrl.searchParams.get("per_page")).toBe("25"); + expect(parsedUrl.searchParams.get("sort")).toBe("-timestamp"); + expect(parsedUrl.searchParams.get("statsPeriod")).toBe("24h"); + expect(parsedUrl.searchParams.has("start")).toBe(false); + expect(parsedUrl.searchParams.has("end")).toBe(false); + }); + + it("should omit full for issue events unless explicitly requested", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkMock([]); + + await apiService.listEventsForIssue({ + organizationSlug: "test-org", + issueId: "123", + statsPeriod: "24h", + }); + + const defaultUrl = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], + ); + expect(new URL(defaultUrl).searchParams.has("full")).toBe(false); + + await apiService.listEventsForIssue({ + organizationSlug: "test-org", + issueId: "123", + statsPeriod: "24h", + full: true, + }); + + const fullUrl = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[1], + ); + expect(["1", "true"]).toContain( + new URL(fullUrl).searchParams.get("full"), + ); + }); + + it("should detect multi-project access errors from SDK error details", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = makeSdkErrorMock( + { + detail: "You do not have the multi project stream feature enabled", + }, + 403, + "Forbidden", + ); + + try { + await apiService.listEventsForIssue({ + organizationSlug: "test-org", + issueId: "123", + statsPeriod: "24h", + }); + throw new Error("Expected listEventsForIssue to reject"); + } catch (error) { + expect(error).toBeInstanceOf(ApiPermissionError); + expect(error).toHaveProperty( + "message", + "You do not have access to query across multiple projects. Please select a project for your query.", + ); + expect((error as ApiPermissionError).isMultiProjectAccessError()).toBe( + true, + ); + } }); }); @@ -1340,40 +1540,48 @@ describe("API query builders", () => { }); const urls: string[] = []; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { - urls.push(url); - - return Promise.resolve({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => - Promise.resolve([ - { - key: "tags[type]", - name: "type", - attributeType: "string", - attributeSource: { - source_type: "sentry", - is_transformed_alias: true, - }, - }, - { - key: "tags[enabled,boolean]", - name: "enabled", - attributeType: "boolean", - attributeSource: { source_type: "user" }, - }, - { - key: "tags[count,number]", - name: "count", - attributeType: "number", + globalThis.fetch = vi + .fn() + .mockImplementation((input: string | Request) => { + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : String(input); + urls.push(url); + // The merged client fetches all attribute types in one request, so the + // mock returns the full set and listTraceItemAttributes filters them. + const body = [ + { + key: "tags[type]", + name: "type", + attributeType: "string", + attributeSource: { + source_type: "sentry", + is_transformed_alias: true, }, - ]), + }, + { + key: "tags[enabled,boolean]", + name: "enabled", + attributeType: "boolean", + attributeSource: { source_type: "user" }, + }, + { + key: "tags[count,number]", + name: "count", + attributeType: "number", + }, + ]; + + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); }); - }); const result = await apiService.listTraceItemAttributes({ organizationSlug: "test-org", @@ -2005,17 +2213,22 @@ describe("API query builders", () => { it("preserves replay project slugs in path parameters", async () => { let requestUrl: string | undefined; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { - requestUrl = url; - return Promise.resolve({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve([["segment-1"]]), + globalThis.fetch = vi + .fn() + .mockImplementation((input: string | Request) => { + requestUrl = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : String(input); + return Promise.resolve( + new Response(JSON.stringify([["segment-1"]]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); }); - }); await apiService.getReplayRecordingSegments({ organizationSlug: "test-org", diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 724832e61..d7348af04 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1,6 +1,55 @@ -import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; import { + parseSentryLinkHeader, + addProjectTeam as sdkAddATeamToAProject, + createProjectKey as sdkCreateANewClientKey, + createTeamProject as sdkCreateANewProject, + createOrganizationTeam as sdkCreateANewTeam, + listProjectKeys as sdkListAProjectSClientKeys, + listProjectReleases as sdkListAProjectSReleases, + listProjectEventAttachments as sdkListAnEventSAttachments, + listOrganizationIssueEvents as sdkListAnIssueSEvents, + listOrganizationIssues as sdkListAnOrganizationSIssues, + listOrganizationProjects as sdkListAnOrganizationSProjects, + listOrganizationReleases as sdkListAnOrganizationSReleases, + listOrganizationReplays as sdkListAnOrganizationSReplays, + listOrganizationTeams as sdkListAnOrganizationSTeams, + listProjectReplayRecordingSegments as sdkListRecordingSegments, + listOrganizationTraceItemAttributes as sdkListTraceItemAttributes, + listOrganizations as sdkListYourOrganizations, + listOrganizationEvents as sdkQueryExploreEvents, + getOrganizationReplayCount as sdkRetrieveACountOfReplays, + getOrganizationProfilingFlamegraph as sdkRetrieveAFlamegraph, + getProjectProfilingProfile as sdkRetrieveAProfile, + getProject as sdkRetrieveAProject, + getOrganizationReplay as sdkRetrieveAReplayInstance, + getOrganizationTrace as sdkRetrieveATrace, + getOrganizationIssue as sdkRetrieveAnIssue, + getOrganizationIssueEvent as sdkRetrieveAnIssueEvent, + getOrganization as sdkRetrieveAnOrganization, + listOrganizationIssueExternalIssues as sdkRetrieveCustomIntegrationIssueLinks, + listOrganizationProfilingChunks as sdkRetrieveProfileChunks, + getOrganizationIssueAutofixState as sdkRetrieveSeerIssueFixState, + getOrganizationIssueTag as sdkRetrieveTagDetails, + getOrganizationTraceMeta as sdkRetrieveTraceMetadata, + startOrganizationIssueAutofix as sdkStartSeerIssueFix, + updateProject as sdkUpdateAProject, + updateOrganizationIssue as sdkUpdateAnIssue, +} from "@sentry/api"; +import type { ListOrganizationReplaysData } from "@sentry/api"; +import { z } from "zod"; +import { ConfigurationError } from "../errors"; +import { logIssue, logWarn } from "../telem/logging"; +import type { SentryProtocol } from "../types"; +import { + type EventsDataset, + isMetricsDataset, + isProfilesDataset, + normalizeEventsDataset, +} from "../utils/events-datasets"; +import { + type DashboardUrlOptions, + type TraceMetricIdentifier, getContinuousProfileUrl as getContinuousProfileUrlUtil, getAIConversationsUrl as getAIConversationsUrlUtil, getAIConversationUrl as getAIConversationUrlUtil, @@ -16,84 +65,78 @@ import { getTraceMetricsExploreUrl, getTraceUrl as getTraceUrlUtil, isSentryHost, - type DashboardUrlOptions, - type TraceMetricIdentifier, } from "../utils/url-utils"; import { isNumericId } from "../utils/slug-validation"; +import { USER_AGENT } from "../version"; +import { ApiNotFoundError, ApiValidationError, createApiError } from "./errors"; import { - isMetricsDataset, - isProfilesDataset, - normalizeEventsDataset, - type EventsDataset, -} from "../utils/events-datasets"; -import { logIssue, logWarn } from "../telem/logging"; -import { - OrganizationListSchema, - OrganizationSchema, + AIConversationSpanListSchema, + ApiErrorSchema, + AutofixRunSchema, + AutofixRunStateSchema, + ClientKeyListSchema, ClientKeySchema, - TeamListSchema, - TeamSchema, - ProjectListSchema, - ProjectRepoLinkSchema, - ProjectSchema, CommitListSchema, + DashboardListSchema, + DashboardSchema, DeployListSchema, - MonitorCheckInListSchema, - MonitorListSchema, - MonitorSchema, - MonitorStatsSchema, - RepositoryListSchema, - ReleaseDetailsSchema, - ReleaseListSchema, + ErrorsSearchResponseSchema, + EventAttachmentListSchema, + EventSchema, + ExternalIssueListSchema, + FlamegraphSchema, IssueActivityListResponseSchema, + IssueAlertRuleListSchema, IssueCommentListSchema, IssueCommentSchema, IssueListSchema, IssueSchema, IssueTagValuesSchema, - ExternalIssueListSchema, - EventSchema, - EventAttachmentListSchema, - ErrorsSearchResponseSchema, - SpansSearchResponseSchema, - TagListSchema, - ApiErrorSchema, - ClientKeyListSchema, - AutofixRunSchema, - AutofixRunStateSchema, - DashboardListSchema, - DashboardSchema, - TraceMetaSchema, - TraceSchema, - UserSchema, - UserRegionsSchema, - IssueAlertRuleListSchema, MetricAlertRuleListSchema, MetricAlertRuleSchema, - FlamegraphSchema, + MonitorCheckInListSchema, + MonitorListSchema, + MonitorSchema, + MonitorStatsSchema, + OrganizationListSchema, + OrganizationSchema, ProfileChunkResponseSchema, - TransactionProfileSchema, + ProjectListSchema, + ProjectRepoLinkSchema, + ProjectSchema, + RepositoryListSchema, + ReleaseDetailsSchema, + ReleaseListSchema, ReplayDetailsSchema, - ReplayListResponseSchema, ReplayIdsByResourceSchema, + ReplayListResponseSchema, ReplayRecordingSegmentsSchema, + SpansSearchResponseSchema, + TagListSchema, + TraceMetaSchema, + TraceSchema, + TeamListSchema, + TeamSchema, + TransactionProfileSchema, + UserRegionsSchema, + UserSchema, AIConversationSummaryListSchema, - AIConversationSpanListSchema, } from "./schema"; -import { ConfigurationError } from "../errors"; -import { createApiError, ApiNotFoundError, ApiValidationError } from "./errors"; -import { USER_AGENT } from "../version"; -import type { SentryProtocol } from "../types"; import type { + AIConversationSpanList, AutofixRun, AutofixRunState, ClientKey, ClientKeyList, + CommitList, Dashboard, DashboardListItem, + DeployList, Event, EventAttachment, EventAttachmentList, + ExternalIssueList, + Flamegraph, Issue, IssueActivityList, IssueAlertRule, @@ -102,34 +145,29 @@ import type { IssueCommentList, IssueList, IssueTagValues, - ExternalIssueList, - CommitList, - DeployList, + MetricAlertRule, + MetricAlertRuleList, Monitor, MonitorCheckInList, MonitorList, MonitorStats, - MetricAlertRule, - MetricAlertRuleList, OrganizationList, + ProfileChunk, Project, ProjectList, ReleaseDetails, ReleaseList, + ReplayDetails, + ReplayList, + ReplayRecordingSegments, TagList, Team, TeamList, Trace, TraceMeta, - User, - Flamegraph, - ProfileChunk, TransactionProfile, - ReplayDetails, - ReplayList, - ReplayRecordingSegments, + User, AIConversationSummary, - AIConversationSpanList, } from "./types"; // TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently // logger isnt exposed (or rather, it is, but its not the right logger) @@ -183,22 +221,9 @@ function parseStatsPeriod(statsPeriod: string): { } function getNextCursor(linkHeader: string | null): string | null { - if (!linkHeader) { - return null; - } - - for (const link of linkHeader.split(",")) { - if (!link.includes('rel="next"') || !link.includes('results="true"')) { - continue; - } - - const cursorMatch = link.match(/cursor="([^"]+)"/); - if (cursorMatch?.[1]) { - return cursorMatch[1]; - } - } - - return null; + // Delegate Link-header parsing to the SDK's pagination helper, which honors + // Sentry's `results="true"` qualifier. Normalize its `undefined` to `null`. + return parseSentryLinkHeader(linkHeader).nextCursor ?? null; } /** @@ -460,12 +485,13 @@ function parseTraceItemAttributes( body: unknown, fallbackType: TraceItemAttributeType, ): TraceItemAttribute[] { - if (!Array.isArray(body)) { + const values = isRecord(body) && Array.isArray(body.data) ? body.data : body; + if (!Array.isArray(values)) { return []; } const attributes: TraceItemAttribute[] = []; - for (const value of body) { + for (const value of values) { if (!isRecord(value) || typeof value.key !== "string") { continue; } @@ -597,6 +623,87 @@ export class SentryApiService { this.apiPrefix = `${this.protocol}://${this.host}/api/0`; } + /** + * Builds the common SDK configuration (baseUrl + auth headers) for an SDK call. + */ + private getSdkConfig(opts?: RequestOptions): { + baseUrl: string; + headers: Record; + } { + const host = opts?.host ?? this.host; + const headers: Record = { + "User-Agent": USER_AGENT, + }; + if (this.accessToken) { + headers.Authorization = `Bearer ${this.accessToken}`; + } + if (this.clientId) { + headers["X-Sentry-MCP-Client-Id"] = this.clientId; + } + if (this.clientName) { + headers["X-Sentry-MCP-Client-Name"] = this.clientName; + } + if (this.clientFamily) { + headers["X-Sentry-MCP-Client-Family"] = this.clientFamily; + } + return { + baseUrl: `${this.protocol}://${host}`, + headers, + }; + } + + /** + * Unwraps an SDK result (`{ data, error }` discriminated union) and converts + * errors to the existing MCP error types. + * + * Typed structurally (not as `SdkResult`) because the SDK's `RequestResult` + * return — whose conditional generic encoding is not assignable to the strict + * `SdkResult` union — still satisfies this minimal shape, letting `TData` infer + * from the SDK's `data` field so the generated response type flows to callers. + * + * @param result The SDK result to unwrap + * @param context A descriptive label for error messages (e.g. method name) + * @returns The data on success + * @throws {ApiError|ApiNotFoundError|ApiValidationError|Error} on failure + */ + private unwrapSdkResult( + result: { data?: TData; error?: unknown; response?: Response }, + context: string, + ): TData { + if (result.error !== undefined) { + const response: Response | undefined = result.response; + if (response) { + // Extract detail from the error object — the SDK parses JSON + // response bodies, so result.error is typically { detail: "..." }. + const rawDetail = + result.error && + typeof result.error === "object" && + "detail" in result.error + ? (result.error as { detail: unknown }).detail + : undefined; + const hasUsableDetail = rawDetail !== null && rawDetail !== undefined; + const detail = hasUsableDetail + ? typeof rawDetail === "string" + ? rawDetail + : JSON.stringify(rawDetail) + : typeof result.error === "string" + ? result.error + : JSON.stringify(result.error); + + throw createApiError( + hasUsableDetail + ? detail + : `${context}: ${response.status} ${response.statusText ?? "Unknown"}`, + response.status, + detail, + result.error, + ); + } + throw new Error(`${context}: ${String(result.error)}`); + } + return result.data as TData; + } + /** * Checks if the current host is Sentry SaaS (sentry.io). * @@ -1566,19 +1673,14 @@ export class SentryApiService { params?: { query?: string }, opts?: RequestOptions, ): Promise { - // Build query parameters - const queryParams = new URLSearchParams(); - queryParams.set("per_page", "25"); - if (params?.query) { - queryParams.set("query", params.query); - } - const queryString = queryParams.toString(); - const path = `/organizations/?${queryString}`; - // For self-hosted instances, the regions endpoint doesn't exist if (!this.isSaas()) { - const body = await this.requestJSON(path, undefined, opts); - return OrganizationListSchema.parse(body); + const result = await sdkListYourOrganizations({ + ...this.getSdkConfig(opts), + query: { query: params?.query, per_page: 25 }, + }); + const data = this.unwrapSdkResult(result, "listOrganizations"); + return OrganizationListSchema.parse(data); } // For SaaS, try to use regions endpoint first @@ -1594,12 +1696,19 @@ export class SentryApiService { const allOrganizations = ( await Promise.all( - regionData.regions.map(async (region) => - this.requestJSON(path, undefined, { - ...opts, - host: new URL(region.url).host, - }), - ), + regionData.regions.map(async (region) => { + const regionResult = await sdkListYourOrganizations({ + ...this.getSdkConfig({ + ...opts, + host: new URL(region.url).host, + }), + query: { query: params?.query, per_page: 25 }, + }); + return this.unwrapSdkResult( + regionResult, + "listOrganizations(region)", + ); + }), ) ) .map((data) => OrganizationListSchema.parse(data)) @@ -1612,8 +1721,12 @@ export class SentryApiService { // fall back to direct organizations endpoint if (error instanceof ApiNotFoundError) { // logger.info("Regions endpoint not found, falling back to direct organizations endpoint"); - const body = await this.requestJSON(path, undefined, opts); - return OrganizationListSchema.parse(body); + const result = await sdkListYourOrganizations({ + ...this.getSdkConfig(opts), + query: { query: params?.query, per_page: 25 }, + }); + const data = this.unwrapSdkResult(result, "listOrganizations"); + return OrganizationListSchema.parse(data); } // Re-throw other errors @@ -1629,12 +1742,12 @@ export class SentryApiService { * @returns Organization data */ async getOrganization(organizationSlug: string, opts?: RequestOptions) { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/`, - undefined, - opts, - ); - return OrganizationSchema.parse(body); + const result = await sdkRetrieveAnOrganization({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + }); + const data = this.unwrapSdkResult(result, "getOrganization"); + return OrganizationSchema.parse(data); } /** @@ -1651,16 +1764,16 @@ export class SentryApiService { params?: { query?: string }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("per_page", "25"); - if (params?.query) { - queryParams.set("query", params.query); - } - const queryString = queryParams.toString(); - const path = `/organizations/${organizationSlug}/teams/?${queryString}`; - - const body = await this.requestJSON(path, undefined, opts); - return TeamListSchema.parse(body); + const result = await sdkListAnOrganizationSTeams({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + per_page: 25, + query: params?.query, + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "listTeams"); + return TeamListSchema.parse(data); } /** @@ -1683,15 +1796,13 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/teams/`, - { - method: "POST", - body: JSON.stringify({ name }), - }, - opts, - ); - return TeamSchema.parse(body); + const result = await sdkCreateANewTeam({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + body: { name }, + }); + const data = this.unwrapSdkResult(result, "createTeam"); + return TeamSchema.parse(data); } /** @@ -1708,16 +1819,17 @@ export class SentryApiService { params?: { query?: string }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("per_page", "25"); - if (params?.query) { - queryParams.set("query", params.query); - } - const queryString = queryParams.toString(); - const path = `/organizations/${organizationSlug}/projects/?${queryString}`; - - const body = await this.requestJSON(path, undefined, opts); - return ProjectListSchema.parse(body); + // The SDK type doesn't include query/per_page params, but the API accepts them + const result = await sdkListAnOrganizationSProjects({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + query: params?.query, + per_page: 25, + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "listProjects"); + return ProjectListSchema.parse(data); } async listDashboards( @@ -1798,12 +1910,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlugOrId}/`, - undefined, - opts, - ); - return ProjectSchema.parse(body); + const result = await sdkRetrieveAProject({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlugOrId, + }, + }); + const data = this.unwrapSdkResult(result, "getProject"); + return ProjectSchema.parse(data); } /** @@ -1831,21 +1946,19 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const createData: Record = { name }; - // Only include platform if it has a meaningful value (not null, undefined, or empty) - if (platform) { - createData.platform = platform; - } - - const body = await this.requestJSON( - `/teams/${organizationSlug}/${teamSlug}/projects/`, - { - method: "POST", - body: JSON.stringify(createData), + const result = await sdkCreateANewProject({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + team_id_or_slug: teamSlug, }, - opts, - ); - return ProjectSchema.parse(body); + body: { + name, + ...(platform ? { platform } : {}), + }, + }); + const data = this.unwrapSdkResult(result, "createProject"); + return ProjectSchema.parse(data); } /** @@ -1876,21 +1989,20 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const updateData: Record = {}; - // Only include fields that have meaningful values (truthy strings) - if (name) updateData.name = name; - if (slug) updateData.slug = slug; - if (platform) updateData.platform = platform; - - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/`, - { - method: "PUT", - body: JSON.stringify(updateData), + const result = await sdkUpdateAProject({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, }, - opts, - ); - return ProjectSchema.parse(body); + body: { + ...(name ? { name } : {}), + ...(slug ? { slug } : {}), + ...(platform ? { platform } : {}), + }, + }); + const data = this.unwrapSdkResult(result, "updateProject"); + return ProjectSchema.parse(data); } async listRepos( @@ -2213,14 +2325,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - await this.request( - `/projects/${organizationSlug}/${projectSlug}/teams/${teamSlug}/`, - { - method: "POST", - body: JSON.stringify({}), + const result = await sdkAddATeamToAProject({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + team_id_or_slug: teamSlug, }, - opts, - ); + }); + this.unwrapSdkResult(result, "addTeamToProject"); } /** @@ -2257,17 +2370,16 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/keys/`, - { - method: "POST", - body: JSON.stringify({ - name, - }), + const result = await sdkCreateANewClientKey({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, }, - opts, - ); - return ClientKeySchema.parse(body); + body: { name }, + }); + const data = this.unwrapSdkResult(result, "createClientKey"); + return ClientKeySchema.parse(data); } /** @@ -2349,12 +2461,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/keys/`, - undefined, - opts, - ); - return ClientKeyListSchema.parse(body); + const result = await sdkListAProjectSClientKeys({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + }, + }); + const data = this.unwrapSdkResult(result, "listClientKeys"); + return ClientKeyListSchema.parse(data); } /** @@ -2393,21 +2508,26 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (query) { - searchQuery.set("query", query); + if (projectSlug) { + const result = await sdkListAProjectSReleases({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + }, + query: { query }, + }); + const data = this.unwrapSdkResult(result, "listReleases(project)"); + return ReleaseListSchema.parse(data); } - const path = projectSlug - ? `/projects/${organizationSlug}/${projectSlug}/releases/` - : `/organizations/${organizationSlug}/releases/`; - - const body = await this.requestJSON( - searchQuery.toString() ? `${path}?${searchQuery.toString()}` : path, - undefined, - opts, - ); - return ReleaseListSchema.parse(body); + const result = await sdkListAnOrganizationSReleases({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { query }, + }); + const data = this.unwrapSdkResult(result, "listReleases"); + return ReleaseListSchema.parse(data); } async getReleaseDetails( @@ -2730,29 +2850,20 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - if (dataset) { - searchQuery.set("dataset", dataset); - } - if (project) { - searchQuery.set("project", project); - } - this.applyTimeParams(searchQuery, statsPeriod, start, end); - if (useCache !== undefined) { - searchQuery.set("useCache", useCache ? "1" : "0"); - } - if (useFlagsBackend !== undefined) { - searchQuery.set("useFlagsBackend", useFlagsBackend ? "1" : "0"); - } + const params = new URLSearchParams(); + if (dataset) params.set("dataset", dataset); + if (project) params.set("project", project); + this.applyTimeParams(params, statsPeriod, start, end); + if (useCache !== undefined) params.set("useCache", useCache ? "1" : "0"); + if (useFlagsBackend !== undefined) + params.set("useFlagsBackend", useFlagsBackend ? "1" : "0"); - const body = await this.requestJSON( - searchQuery.toString() - ? `/organizations/${organizationSlug}/tags/?${searchQuery.toString()}` - : `/organizations/${organizationSlug}/tags/`, + const data = await this.requestJSON( + `/organizations/${organizationSlug}/tags/?${params}`, undefined, opts, ); - return TagListSchema.parse(body); + return TagListSchema.parse(data); } async searchReplays( @@ -2781,44 +2892,45 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const searchQuery = new URLSearchParams(); - - if (query) { - searchQuery.set("query", query); - } - if (limit !== undefined) { - searchQuery.set("per_page", String(limit)); - } - if (projectId) { - searchQuery.append("project", projectId); - } - if (sort) { - searchQuery.set("sort", sort); - } - if (environment) { - const environments = Array.isArray(environment) - ? environment - : [environment]; - for (const value of environments) { - searchQuery.append("environment", value); - } + if (statsPeriod && (start || end)) { + throw new ApiValidationError( + "Cannot use both statsPeriod and start/end parameters", + ); } - if (fields && fields.length > 0) { - for (const field of fields) { - searchQuery.append("field", field); - } + if ((start && !end) || (!start && end)) { + throw new ApiValidationError( + "Both start and end parameters must be provided together", + ); } - this.applyTimeParams(searchQuery, statsPeriod, start, end); - const body = await this.requestJSON( - searchQuery.toString() - ? `/organizations/${organizationSlug}/replays/?${searchQuery.toString()}` - : `/organizations/${organizationSlug}/replays/`, - undefined, - opts, - ); + const result = await sdkListAnOrganizationSReplays({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + query, + per_page: limit, + sort, + statsPeriod, + start, + end, + environment, + // Pass projectId through as-is: the SDK types `project` as + // Array (IDs or slugs), so coercing with Number() + // would turn a slug into NaN and break slug-based filtering. + ...(projectId ? { project: [projectId] } : {}), + // SDK types field as a strict enum — the API accepts arbitrary strings at runtime + ...(fields?.length + ? { + field: fields as NonNullable< + ListOrganizationReplaysData["query"] + >["field"], + } + : {}), + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "searchReplays"); - return ReplayListResponseSchema.parse(body).data; + return ReplayListResponseSchema.parse(data).data; } /** @@ -2950,23 +3062,32 @@ export class SentryApiService { query?: string, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("itemType", itemType); + const queryParams: Record = { + itemType, + }; if (project) { - queryParams.set("project", project); + queryParams.project = project; } if (substringMatch) { - queryParams.set("substringMatch", substringMatch); + queryParams.substringMatch = substringMatch; } if (query) { - queryParams.set("query", query); + queryParams.query = query; + } + if (statsPeriod) { + queryParams.statsPeriod = statsPeriod; + } else if (start && end) { + queryParams.start = start; + queryParams.end = end; } - this.applyTimeParams(queryParams, statsPeriod, start, end); - - const url = `/organizations/${organizationSlug}/trace-items/attributes/?${queryParams.toString()}`; - const body = await this.requestJSON(url, undefined, opts); - return parseTraceItemAttributes(body, "string"); + const result = await sdkListTraceItemAttributes({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: queryParams, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "listTraceItemAttributes"); + return parseTraceItemAttributes(data, "string"); } /** @@ -2977,9 +3098,10 @@ export class SentryApiService { * * @param params Query parameters * @param params.organizationSlug Organization identifier - * @param params.projectSlug Project identifier (optional, scopes to specific project) + * @param params.projectId Project ID (optional, filters to a specific project) * @param params.query Sentry search query (e.g., "is:unresolved browser:chrome") * @param params.sortBy Sort order ("user", "freq", "date", "new") + * @param params.statsPeriod Search time window (e.g., "24h", "30d") * @param opts Request options * @returns Array of issues with metadata and statistics * @@ -3030,21 +3152,23 @@ export class SentryApiService { sentryQuery.push(query); } - const queryParams = new URLSearchParams(); - queryParams.set("limit", String(limit)); - if (sortBy) queryParams.set("sort", sortBy); - queryParams.set("statsPeriod", statsPeriod ?? DEFAULT_SEARCH_ISSUES_PERIOD); - queryParams.set("query", sentryQuery.join(" ")); - - if (projectId) { - queryParams.append("project", projectId); - } - queryParams.append("collapse", "unhandled"); - - const apiUrl = `/organizations/${organizationSlug}/issues/?${queryParams.toString()}`; - - const body = await this.requestJSON(apiUrl, undefined, opts); - return IssueListSchema.parse(body); + // Filter by project via the org-issues `project` query param (project IDs). + // The SDK type doesn't include sort/collapse/project for this op, so we + // pass the query via cast. + const result = await sdkListAnOrganizationSIssues({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + limit, + sort: sortBy, + statsPeriod, + query: sentryQuery.join(" "), + collapse: ["unhandled"], + ...(projectId ? { project: [projectId] } : {}), + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "listIssues"); + return IssueListSchema.parse(data); } async getIssue( @@ -3057,12 +3181,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/`, - undefined, - opts, - ); - return IssueSchema.parse(body); + const result = await sdkRetrieveAnIssue({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + }); + const data = this.unwrapSdkResult(result, "getIssue"); + return IssueSchema.parse(data); } /** @@ -3102,12 +3229,16 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/tags/${tagKey}/`, - undefined, - opts, - ); - return IssueTagValuesSchema.parse(body); + const result = await sdkRetrieveTagDetails({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + key: tagKey, + }, + }); + const data = this.unwrapSdkResult(result, "getIssueTagValues"); + return IssueTagValuesSchema.parse(data); } /** @@ -3132,12 +3263,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/external-issues/`, - undefined, - opts, - ); - return ExternalIssueListSchema.parse(body); + const result = await sdkRetrieveCustomIntegrationIssueLinks({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + }); + const data = this.unwrapSdkResult(result, "getIssueExternalLinks"); + return ExternalIssueListSchema.parse(data); } async getEventForIssue( @@ -3152,11 +3286,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`, - undefined, - opts, - ); + const result = await sdkRetrieveAnIssueEvent({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + event_id: eventId as "latest" | "oldest" | "recommended", + }, + }); + const body = this.unwrapSdkResult(result, "getEventForIssue"); // Try to parse with known event schemas first const parseResult = EventSchema.safeParse(body); @@ -3273,31 +3411,34 @@ export class SentryApiService { }, opts?: RequestOptions, ) { - const params = new URLSearchParams(); - + const sdkQuery: Record = { + per_page: limit, + }; if (query) { - params.append("query", query); + sdkQuery.query = query; } - - params.append("per_page", String(limit)); - if (sort) { - params.append("sort", sort); + sdkQuery.sort = sort; } - if (statsPeriod) { - params.append("statsPeriod", statsPeriod); + sdkQuery.statsPeriod = statsPeriod; } else if (start && end) { - params.append("start", start); - params.append("end", end); + sdkQuery.start = start; + sdkQuery.end = end; } - if (full) { - params.append("full", "true"); + sdkQuery.full = true; } - const apiUrl = `/organizations/${organizationSlug}/issues/${issueId}/events/?${params.toString()}`; - return await this.requestJSON(apiUrl, undefined, opts); + const result = await sdkListAnIssueSEvents({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + query: sdkQuery, + }); + return this.unwrapSdkResult(result, "listEventsForIssue"); } async listEventAttachments( @@ -3312,12 +3453,16 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`, - undefined, - opts, - ); - return EventAttachmentListSchema.parse(body); + const result = await sdkListAnEventSAttachments({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + event_id: eventId, + }, + }); + const data = this.unwrapSdkResult(result, "listEventAttachments"); + return EventAttachmentListSchema.parse(data); } async getEventAttachment( @@ -3340,14 +3485,12 @@ export class SentryApiService { blob: Blob; contentType: string; }> { - // Get the attachment metadata first - const attachmentsData = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`, - undefined, + // Get the attachment metadata via SDK + const attachments = await this.listEventAttachments( + { organizationSlug, projectSlug, eventId }, opts, ); - const attachments = EventAttachmentListSchema.parse(attachmentsData); const attachment = attachments.find((att) => att.id === attachmentId); if (!attachment) { @@ -3356,7 +3499,8 @@ export class SentryApiService { ); } - // Download the actual file content + // Download the actual file content — SDK doesn't support binary blob + // responses, so we keep using raw request() for the download. const downloadUrl = `/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/${attachmentId}/?download=1`; const downloadResponse = await this.request( downloadUrl, @@ -3391,12 +3535,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/replays/${replayId}/`, - undefined, - opts, - ); - return z.object({ data: ReplayDetailsSchema }).parse(body).data; + const result = await sdkRetrieveAReplayInstance({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + replay_id: replayId, + }, + }); + const data = this.unwrapSdkResult(result, "getReplayDetails"); + return z.object({ data: ReplayDetailsSchema }).parse(data).data; } async listReplayIdsForIssue( @@ -3412,20 +3559,24 @@ export class SentryApiService { opts?: RequestOptions, ): Promise { const normalizedIssueId = String(issueId); - const queryParams = new URLSearchParams(); - queryParams.set("returnIds", "true"); - queryParams.set("query", `issue.id:[${normalizedIssueId}]`); - queryParams.set("data_source", dataSource); - queryParams.set("statsPeriod", "90d"); - queryParams.append("project", "-1"); - - const body = await this.requestJSON( - `/organizations/${organizationSlug}/replay-count/?${queryParams.toString()}`, - undefined, - opts, - ); + // `project` is not in the SDK type — it is processed by the base class + // (OrganizationEventsEndpointBase.get_snuba_params) rather than the endpoint validator. + // -1 is the sentinel for all-accessible projects; without it the API returns only + // projects the caller is a member of, which misses replays in access-only projects. + const result = await sdkRetrieveACountOfReplays({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + query: `issue.id:[${normalizedIssueId}]`, + statsPeriod: "90d", + returnIds: true, + data_source: dataSource, + project: -1, + }, + } as unknown as Parameters[0]); + const data = this.unwrapSdkResult(result, "listReplayIdsForIssue"); - const replayIdsByResource = ReplayIdsByResourceSchema.parse(body); + const replayIdsByResource = ReplayIdsByResourceSchema.parse(data); return replayIdsByResource[normalizedIssueId] ?? []; } @@ -3441,12 +3592,18 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/projects/${organizationSlug}/${projectSlugOrId}/replays/${replayId}/recording-segments/?download=true`, - undefined, - opts, - ); - return ReplayRecordingSegmentsSchema.parse(body); + // The SDK doesn't expose the `download` query param, so pass it via cast + const result = await sdkListRecordingSegments({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlugOrId, + replay_id: replayId, + }, + query: { download: "true" }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "getReplayRecordingSegments"); + return ReplayRecordingSegmentsSchema.parse(data); } async updateIssue( @@ -3475,16 +3632,9 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const updateData: { - status?: string; - assignedTo?: string; - substatus?: string; - ignoreDuration?: number; - ignoreCount?: number; - ignoreWindow?: number; - ignoreUserCount?: number; - ignoreUserWindow?: number; - } = {}; + // The SDK body type is stricter than what we send (extra fields like + // substatus, ignoreDuration, etc.), so we cast. + const updateData: Record = {}; if (status !== undefined) updateData.status = status; if (assignedTo !== undefined) updateData.assignedTo = assignedTo; if (substatus !== undefined) updateData.substatus = substatus; @@ -3499,15 +3649,16 @@ export class SentryApiService { updateData.ignoreUserWindow = ignoreUserWindow; } - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/`, - { - method: "PUT", - body: JSON.stringify(updateData), + const result = await sdkUpdateAnIssue({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, }, - opts, - ); - return IssueSchema.parse(body); + body: updateData as Parameters[0]["body"], + }); + const data = this.unwrapSdkResult(result, "updateIssue"); + return IssueSchema.parse(data); } async createIssueComment( @@ -3625,28 +3776,22 @@ export class SentryApiService { sentryQuery.push(`project:${projectSlug}`); } - const queryParams = new URLSearchParams(); - queryParams.set("dataset", "errors"); - queryParams.set("per_page", "10"); - queryParams.set( - "sort", - `-${sortBy === "last_seen" ? "last_seen" : "count"}`, - ); - queryParams.set("statsPeriod", "24h"); - queryParams.append("field", "issue"); - queryParams.append("field", "title"); - queryParams.append("field", "project"); - queryParams.append("field", "last_seen()"); - queryParams.append("field", "count()"); - queryParams.set("query", sentryQuery.join(" ")); - // if (projectSlug) queryParams.set("project", projectSlug); - - const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`; - - const body = await this.requestJSON(apiUrl, undefined, opts); + const result = await sdkQueryExploreEvents({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + dataset: "errors", + per_page: 10, + sort: `-${sortBy === "last_seen" ? "last_seen" : "count"}`, + statsPeriod: "24h", + field: ["issue", "title", "project", "last_seen()", "count()"], + query: sentryQuery.join(" "), + }, + }); + const data = this.unwrapSdkResult(result, "searchErrors"); // TODO(dcramer): If you're using an older version of Sentry this API had a breaking change // meaning this endpoint will error. - return ErrorsSearchResponseSchema.parse(body).data; + return ErrorsSearchResponseSchema.parse(data).data; } async searchSpans( @@ -3676,30 +3821,29 @@ export class SentryApiService { sentryQuery.push(`project:${projectSlug}`); } - const queryParams = new URLSearchParams(); - queryParams.set("dataset", "spans"); - queryParams.set("per_page", "10"); - queryParams.set( - "sort", - `-${sortBy === "timestamp" ? "timestamp" : "span.duration"}`, - ); - queryParams.set("allowAggregateConditions", "0"); - queryParams.set("useRpc", "1"); - queryParams.append("field", "id"); - queryParams.append("field", "trace"); - queryParams.append("field", "span.op"); - queryParams.append("field", "span.description"); - queryParams.append("field", "span.duration"); - queryParams.append("field", "transaction"); - queryParams.append("field", "project"); - queryParams.append("field", "timestamp"); - queryParams.set("query", sentryQuery.join(" ")); - // if (projectSlug) queryParams.set("project", projectSlug); - - const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`; - - const body = await this.requestJSON(apiUrl, undefined, opts); - return SpansSearchResponseSchema.parse(body).data; + const result = await sdkQueryExploreEvents({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + dataset: "spans", + per_page: 10, + sort: `-${sortBy === "timestamp" ? "timestamp" : "span.duration"}`, + field: [ + "id", + "trace", + "span.op", + "span.description", + "span.duration", + "transaction", + "project", + "timestamp", + ], + query: sentryQuery.join(" "), + allowAggregateConditions: false, + }, + }); + const data = this.unwrapSdkResult(result, "searchSpans"); + return SpansSearchResponseSchema.parse(data).data; } // ================================================================================ @@ -3838,6 +3982,8 @@ export class SentryApiService { }, opts?: RequestOptions, ) { + // Build the full query params using existing builders, then convert to SDK format. + // This preserves the dataset-specific logic (sort transforms, sampling, etc.) let queryParams: URLSearchParams; const normalizedDataset = normalizeEventsDataset(dataset); @@ -3846,7 +3992,6 @@ export class SentryApiService { normalizedDataset === "tracemetrics" || normalizedDataset === "profiles" ) { - // Use Discover API query builder queryParams = this.buildDiscoverApiQuery({ query, fields, @@ -3859,7 +4004,6 @@ export class SentryApiService { sort, }); } else { - // Use EAP API query builder for spans and logs queryParams = this.buildEapApiQuery({ query, fields, @@ -3873,8 +4017,24 @@ export class SentryApiService { }); } - const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`; - return await this.requestJSON(apiUrl, undefined, opts); + // Convert URLSearchParams to SDK query format. Some params like `field` and + // `project` can appear multiple times, while the SDK expects `field` as string[]. + const sdkQuery: Record = {}; + const multiValueKeys = new Set(["field", "project"]); + for (const key of new Set(queryParams.keys())) { + if (multiValueKeys.has(key)) { + sdkQuery[key] = queryParams.getAll(key); + } else { + sdkQuery[key] = queryParams.get(key); + } + } + + const result = await sdkQueryExploreEvents({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: sdkQuery, + } as Parameters[0]); + return this.unwrapSdkResult(result, "searchEvents"); } // POST https://us.sentry.io/api/0/issues/5485083130/autofix/ @@ -3892,18 +4052,19 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/autofix/`, - { - method: "POST", - body: JSON.stringify({ - event_id: eventId, - instruction, - }), + const result = await sdkStartSeerIssueFix({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, }, - opts, - ); - return AutofixRunSchema.parse(body); + body: { + event_id: eventId, + instruction, + } as Parameters[0]["body"], + }); + const data = this.unwrapSdkResult(result, "startAutofix"); + return AutofixRunSchema.parse(data); } // GET https://us.sentry.io/api/0/issues/5485083130/autofix/ @@ -3917,12 +4078,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - `/organizations/${organizationSlug}/issues/${issueId}/autofix/`, - undefined, - opts, - ); - return AutofixRunStateSchema.parse(body); + const result = await sdkRetrieveSeerIssueFixState({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + }); + const data = this.unwrapSdkResult(result, "getAutofixState"); + return AutofixRunStateSchema.parse(data); } /** @@ -3959,15 +4123,16 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("statsPeriod", statsPeriod); - - const body = await this.requestJSON( - `/organizations/${organizationSlug}/trace-meta/${traceId}/?${queryParams.toString()}`, - undefined, - opts, - ); - return TraceMetaSchema.parse(body); + const result = await sdkRetrieveTraceMetadata({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + trace_id: traceId, + }, + query: { statsPeriod }, + }); + const data = this.unwrapSdkResult(result, "getTraceMeta"); + return TraceMetaSchema.parse(data); } /** @@ -4011,19 +4176,22 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - // Keep sending the endpoint's declared query parameters even though the - // current server implementation ignores `project` and paginates internally. - queryParams.set("limit", String(limit)); - queryParams.set("project", project); - queryParams.set("statsPeriod", statsPeriod); - - const body = await this.requestJSON( - `/organizations/${organizationSlug}/trace/${traceId}/?${queryParams.toString()}`, - undefined, - opts, - ); - return TraceSchema.parse(body); + // The SDK type doesn't include limit/project query params, but the API + // accepts them — pass via cast, matching the pattern used elsewhere. + const result = await sdkRetrieveATrace({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + trace_id: traceId, + }, + query: { + statsPeriod, + limit: String(limit), + project, + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "getTrace"); + return TraceSchema.parse(data); } /** @@ -4202,21 +4370,24 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("project", projectId.toString()); // Escape backslashes first, then quotes for proper string escaping const escapedTransaction = transactionName .replace(/\\/g, "\\\\") .replace(/"/g, '\\"'); - queryParams.set( - "query", - `event.type:transaction transaction:"${escapedTransaction}"`, - ); - queryParams.set("statsPeriod", statsPeriod); - const path = `/organizations/${organizationSlug}/profiling/flamegraph/?${queryParams.toString()}`; - const body = await this.requestJSON(path, undefined, opts); - return FlamegraphSchema.parse(body); + const result = await sdkRetrieveAFlamegraph({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + // SDK types `project` as Array (IDs or slugs); forward + // projectId as-is. Number() would turn a slug into NaN and break lookups. + project: [projectId], + query: `event.type:transaction transaction:"${escapedTransaction}"`, + statsPeriod, + }, + }); + const data = this.unwrapSdkResult(result, "getFlamegraph"); + return FlamegraphSchema.parse(data); } async getTransactionProfile( @@ -4231,9 +4402,16 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const path = `/projects/${organizationSlug}/${projectSlugOrId}/profiling/profiles/${profileId}/`; - const body = await this.requestJSON(path, undefined, opts); - return TransactionProfileSchema.parse(body); + const result = await sdkRetrieveAProfile({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: String(projectSlugOrId), + profile_id: profileId, + }, + }); + const data = this.unwrapSdkResult(result, "getTransactionProfile"); + return TransactionProfileSchema.parse(data); } /** @@ -4287,14 +4465,19 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("profiler_id", profilerId); - queryParams.set("project", projectId.toString()); - queryParams.set("start", start); - queryParams.set("end", end); - - const path = `/organizations/${organizationSlug}/profiling/chunks/?${queryParams.toString()}`; - const body = await this.requestJSON(path, undefined, opts); + const result = await sdkRetrieveProfileChunks({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + profiler_id: profilerId, + // SDK types `project` as a string (ID or slug). Stringify so a numeric + // projectId is sent as-is and slugs are preserved. + project: String(projectId), + start, + end, + }, + }); + const body = this.unwrapSdkResult(result, "getProfileChunk"); // Normalize the Sentry {chunk: ...} wrapper to the internal chunks array. const response = ProfileChunkResponseSchema.parse(body); diff --git a/packages/mcp-core/src/api-client/errors.ts b/packages/mcp-core/src/api-client/errors.ts index d80fee97e..26bf5f715 100644 --- a/packages/mcp-core/src/api-client/errors.ts +++ b/packages/mcp-core/src/api-client/errors.ts @@ -256,11 +256,12 @@ export function createApiError( let improvedMessage = message; // Handle the multi-project access error that comes in various forms + const knownErrorText = `${message}\n${detail ?? ""}`; if ( - message.includes( + knownErrorText.includes( "You do not have the multi project stream feature enabled", ) || - message.includes("You cannot view events from multiple projects") + knownErrorText.includes("You cannot view events from multiple projects") ) { improvedMessage = "You do not have access to query across multiple projects. Please select a project for your query."; diff --git a/packages/mcp-core/src/api-client/schema.test.ts b/packages/mcp-core/src/api-client/schema.test.ts index 60b0d996c..639bdff84 100644 --- a/packages/mcp-core/src/api-client/schema.test.ts +++ b/packages/mcp-core/src/api-client/schema.test.ts @@ -656,10 +656,14 @@ describe("TraceMetaSchema", () => { }); describe("AutofixRunSchema", () => { - it("accepts the numeric run_id returned by the autofix POST endpoint", () => { - const run = AutofixRunSchema.parse({ run_id: 123 }); + it("accepts the run_id + sentry_run_id returned by the autofix POST endpoint", () => { + const run = AutofixRunSchema.parse({ + run_id: 123, + sentry_run_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + }); expect(run.run_id).toBe(123); + expect(run.sentry_run_id).toBe("f47ac10b-58cc-4372-a567-0e02b2c3d479"); }); }); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 664ffd129..46b9df846 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -36,6 +36,12 @@ * ``` */ import { z } from "zod"; +import { + zAutofixPostResponse, + zGroupExternalIssueResponse, + zOrganizationEventsResponseDict, + zTagKeyDetailsDict, +} from "@sentry/api/zod"; /** * Schema for Sentry API error responses. @@ -673,13 +679,8 @@ export const IssueCommentListSchema = z.array(IssueCommentSchema); * - `src/sentry/tagstore/types.py` (`TagKeySerializerResponse`) * - `src/sentry/api/endpoints/organization_tags.py` */ -export const TagSchema = z - .object({ - key: z.string(), - name: z.string(), - totalValues: z.number().nullable().optional(), - uniqueValues: z.number().nullable().optional(), - }) +export const TagSchema = zTagKeyDetailsDict + .pick({ key: true, name: true, totalValues: true, uniqueValues: true }) .transform((tag) => ({ key: tag.key, name: tag.name, @@ -1074,14 +1075,13 @@ export const EventSchema = z.union([ UnknownEventSchema, ]); -export const EventsResponseSchema = z.object({ - data: z.array(z.unknown()), - meta: z - .object({ - fields: z.record(z.string(), z.string()), - }) - .passthrough(), -}); +/** + * Uses auto-generated schema from `@sentry/api/zod`. + * + * The generated schema includes optional `datasetReason`, `isMetricsData`, and + * `isMetricsExtractedData` fields on `meta` beyond the required `fields`. + */ +export const EventsResponseSchema = zOrganizationEventsResponseDict; // https://us.sentry.io/api/0/organizations/sentry/events/?dataset=errors&field=issue&field=title&field=project&field=timestamp&field=trace&per_page=5&query=event.type%3Aerror&referrer=api.mcp.search-events&sort=-timestamp&statsPeriod=1w export const ErrorsSearchResponseSchema = EventsResponseSchema.extend({ @@ -1115,15 +1115,13 @@ export const SpansSearchResponseSchema = EventsResponseSchema.extend({ /** * The Seer autofix POST endpoint currently returns a simple numeric `run_id`. * + * Uses auto-generated schema from `@sentry/api/zod`. + * * Upstream source of truth in getsentry/sentry: * - `src/sentry/seer/endpoints/group_ai_autofix.py` * - `src/sentry/seer/autofix/types.py` (`AutofixPostResponse`) */ -export const AutofixRunSchema = z - .object({ - run_id: z.number(), - }) - .passthrough(); +export const AutofixRunSchema = zAutofixPostResponse.passthrough(); // Run statuses from Sentry's `SeerRunState` (`seer/agent/client_models.py`). const AutofixStatusSchema = z.enum([ @@ -1244,18 +1242,13 @@ export const IssueTagValuesSchema = z /** * Schema for external issue link (e.g., Jira, GitHub Issues). * + * Uses auto-generated schema from `@sentry/api/zod`. + * * Represents a link between a Sentry issue and an external issue tracking * system like Jira, GitHub Issues, GitLab, etc. */ -export const ExternalIssueSchema = z.object({ - id: z.union([z.string(), z.number()]), - issueId: z.union([z.string(), z.number()]), - serviceType: z.string(), - displayName: z.string(), - webUrl: z.string(), -}); - -export const ExternalIssueListSchema = z.array(ExternalIssueSchema); +export const ExternalIssueListSchema = zGroupExternalIssueResponse; +export const ExternalIssueSchema = zGroupExternalIssueResponse.element; /** * Schema for Sentry trace metadata response. diff --git a/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts b/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts index 9730a0555..dbf332440 100644 --- a/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts +++ b/packages/mcp-core/src/internal/agents/openai-provider.integration.test.ts @@ -12,7 +12,7 @@ * - #623: structuredOutputs causing validation errors with nullable fields * - 405 errors from unsupported parameters (reasoningEffort) */ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { searchIssuesAgent } from "../../tools/support/search-issues/agent"; @@ -45,14 +45,21 @@ const mswServer = setupServer( ); describe("OpenAI Provider Integration", () => { - const hasOpenAIKey = Boolean(process.env.OPENAI_API_KEY); + const openAIKey = process.env.OPENAI_API_KEY; + const hasOpenAIKey = Boolean(openAIKey); beforeAll(() => { if (hasOpenAIKey) { + mswServer.listen({ onUnhandledRequest: "bypass" }); + } + }); + + beforeEach(() => { + if (openAIKey) { + process.env.OPENAI_API_KEY = openAIKey; // Explicitly set OpenAI provider to ensure we test OpenAI even if // ANTHROPIC_API_KEY is also set (auto-detect prefers Anthropic) setAgentProvider("openai"); - mswServer.listen({ onUnhandledRequest: "bypass" }); } }); diff --git a/packages/mcp-core/src/test-setup.ts b/packages/mcp-core/src/test-setup.ts index 586b561c4..9187b02f9 100644 --- a/packages/mcp-core/src/test-setup.ts +++ b/packages/mcp-core/src/test-setup.ts @@ -2,6 +2,7 @@ import { config } from "dotenv"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { startMockServer } from "@sentry/mcp-server-mocks"; +import { afterEach, beforeEach } from "vitest"; import type { ServerContext } from "./types.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -16,6 +17,38 @@ config({ path: path.resolve(__dirname, "../.env") }); // Load root .env second (for shared defaults - won't override local or shell vars) config({ path: path.join(rootDir, ".env") }); +const MANAGED_ENV_KEYS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "EMBEDDED_AGENT_PROVIDER", + "OPENAI_API_KEY", + "OPENAI_API_VERSION", + "OPENAI_MODEL", +] as const; + +type ManagedEnvKey = (typeof MANAGED_ENV_KEYS)[number]; + +const originalEnv = new Map( + MANAGED_ENV_KEYS.map((key) => [key, process.env[key]]), +); + +beforeEach(() => { + for (const key of MANAGED_ENV_KEYS) { + Reflect.deleteProperty(process.env, key); + } +}); + +afterEach(() => { + for (const key of MANAGED_ENV_KEYS) { + const value = originalEnv.get(key); + if (value === undefined) { + Reflect.deleteProperty(process.env, key); + } else { + process.env[key] = value; + } + } +}); + startMockServer({ ignoreOpenAI: true }); /** diff --git a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts index 29511c3a4..be2a367b7 100644 --- a/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts +++ b/packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts @@ -392,6 +392,7 @@ describe("analyze_issue_with_seer", () => { }); return HttpResponse.json({ run_id: 123, + sentry_run_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", }); }, ), diff --git a/packages/mcp-core/src/tools/catalog/get-issue-tag-values.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-tag-values.test.ts index 918bbfc4f..cc35a7da4 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-tag-values.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-tag-values.test.ts @@ -126,34 +126,20 @@ describe("get_issue_tag_values", () => { ).rejects.toThrow(UserInputError); }); - it("throws error when tagKey contains path traversal characters", async () => { - await expect( - getIssueTagValues.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - tagKey: "../../../admin", - regionUrl: null, - issueUrl: undefined, - }, - getServerContext(), - ), - ).rejects.toThrow(); + it("rejects tagKey with path traversal characters in the input schema", () => { + expect(() => + getIssueTagValues.inputSchema.tagKey.parse("../../../admin"), + ).toThrow( + /Tag key must contain only alphanumeric characters, dots, hyphens, and underscores/, + ); }); - it("throws error when tagKey contains slashes", async () => { - await expect( - getIssueTagValues.handler( - { - organizationSlug: "sentry-mcp-evals", - issueId: "CLOUDFLARE-MCP-41", - tagKey: "url/path", - regionUrl: null, - issueUrl: undefined, - }, - getServerContext(), - ), - ).rejects.toThrow(); + it("rejects tagKey with slashes in the input schema", () => { + expect(() => + getIssueTagValues.inputSchema.tagKey.parse("url/path"), + ).toThrow( + /Tag key must contain only alphanumeric characters, dots, hyphens, and underscores/, + ); }); it("handles null values in topValues gracefully", async () => { 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 effa757ea..5ee1b719b 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import searchEvents from "./search-events"; import { MAX_EVENTS_VALIDATION_ATTEMPTS } from "../support/search-events/utils"; import { generateText } from "ai"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserInputError } from "../../errors"; // Mock the AI SDK diff --git a/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts b/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts index 87e16b051..c17d71e95 100644 --- a/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issue-events.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; -import searchIssueEvents from "./search-issue-events"; import { generateText } from "ai"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserInputError } from "../../errors"; import type { ServerContext } from "../../types"; +import searchIssueEvents from "./search-issue-events"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { @@ -561,9 +561,9 @@ describe("search_issue_events", () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( - http.get("*/api/0/organizations/*/issues/*/events/", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("per_page")).toBe("25"); + http.get("*/api/0/organizations/*/issues/*/events/", () => { + // The SDK's listAnIssueSEvents endpoint doesn't expose per_page + // as a query param; limit is handled at the SDK/pagination layer. return HttpResponse.json([]); }), ); diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index d0a8a8c40..aca5f2922 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -444,6 +444,7 @@ describe("search_issues", () => { mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); + // The SDK sends `limit` (not `per_page`) for this endpoint const limit = url.searchParams.get("limit"); expect(limit).toBe("25"); return HttpResponse.json([]); 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 fa9bda020..4867b40b1 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 @@ -259,7 +259,7 @@ describe("fetchCustomAttributes", () => { ), ); - // Should throw ApiPermissionError with the improved error message + // Should throw ApiPermissionError with the improved error message. await expect( fetchCustomAttributes(apiService, "test-org", "spans"), ).rejects.toThrow( @@ -282,7 +282,7 @@ describe("fetchCustomAttributes", () => { ), ); - // Should throw ApiPermissionError with the raw error message + // Should throw ApiPermissionError with the API detail message. await expect( fetchCustomAttributes(apiService, "test-org", "logs", "project-123"), ).rejects.toThrow("Permission denied"); @@ -360,9 +360,11 @@ describe("fetchCustomAttributes", () => { ), ); + // The SDK wraps network errors — the context prefix is added by unwrapSdkResult. + // Attributes are fetched in a single request, so the context has no type suffix. await expect( fetchCustomAttributes(apiService, "test-org", "spans"), - ).rejects.toThrow("Network error: ETIMEDOUT"); + ).rejects.toThrow("listTraceItemAttributes:"); }); }); diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 1deb769be..9f77b5fdb 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1172,12 +1172,20 @@ export const restHandlers = buildHandlers([ { method: "post", path: "/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-42/autofix/", - fetch: () => HttpResponse.json({ run_id: 123 }), + fetch: () => + HttpResponse.json({ + run_id: 123, + sentry_run_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + }), }, { method: "post", path: "/api/0/organizations/sentry-mcp-evals/issues/PEATED-A8/autofix/", - fetch: () => HttpResponse.json({ run_id: 123 }), + fetch: () => + HttpResponse.json({ + run_id: 123, + sentry_run_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + }), }, { method: "get", @@ -1291,8 +1299,7 @@ export const restHandlers = buildHandlers([ { method: "post", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/:teamSlug/", - fetch: async ({ request, params }) => { - const body = (await request.json()) as any; + fetch: async ({ params }) => { const teamSlug = params.teamSlug as string; return HttpResponse.json({ ...teamFixture, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ab494f93..1eab84a42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@sentry/api': + specifier: ^0.248.0 + version: 0.248.0(zod@3.25.76) '@sentry/core': specifier: 'catalog:' version: 10.54.0 @@ -687,12 +690,14 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@ast-grep/cli-linux-x64-gnu@0.43.0': resolution: {integrity: sha512-r/o9Mag6OZmGevY9OJjatuUKDOX1rSvgo29qSfxpMbIciiH3hkzEW/2w1xTPZI8xnM7iC+k+CkGoknmoXVTYGg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@ast-grep/cli-win32-arm64-msvc@0.43.0': resolution: {integrity: sha512-oHa4ruD87xccnqFuR+Pmx6F/suHV0YtibuyZ6SxUqgpNJAFZiUNAiFblzhEgQ5gp03e8B012P/Yy/7GYOvxOLg==} @@ -834,24 +839,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@1.9.4': resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@1.9.4': resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@1.9.4': resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@1.9.4': resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} @@ -1424,155 +1433,183 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -1956,21 +1993,25 @@ packages: resolution: {integrity: sha512-PFBBnj9JqLOL8gjZtoVGfOXe0PSpnPUXE+JuMcWz568K/p4Zzk7lDDHl7guD95wVtV89TmfaRwK2PWd9vKxHtg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.23': resolution: {integrity: sha512-KyQRLofVP78yUCXT90YmEzxK6I9VCBeOTSyOrs40Qx0Q0XwaGVwxo7sKj2SmnqxribdcouBA3CfNZC4ZNcyEnQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.23': resolution: {integrity: sha512-EubfEsJyjQbKK9j3Ez1hhbIOsttABb07Z7PhMRcVYW0wrVr8SfKLew9pULIMfcSNnoz8QqzoI4lOSmezJ9bYWw==} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.23': resolution: {integrity: sha512-MUAthvl3I/+hySltZuj5ClKiq8fAMqExeBnxadLFShwWCbdHKFd+aRjBxxzarPcnqbDlTaOCUaAaYmQTOTOHSg==} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-wasm32-wasi@1.0.0-beta.23': resolution: {integrity: sha512-YI7QMQU01QFVNTEaQt3ysrq+wGBwLdFVFEGO64CoZ3gTsr/HulU8gvgR+67coQOlQC9iO/Hm1bvkBtceLxKrnA==} @@ -2032,56 +2073,67 @@ packages: resolution: {integrity: sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.1': resolution: {integrity: sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.1': resolution: {integrity: sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.1': resolution: {integrity: sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.1': resolution: {integrity: sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.1': resolution: {integrity: sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.1': resolution: {integrity: sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.1': resolution: {integrity: sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.1': resolution: {integrity: sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.1': resolution: {integrity: sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.1': resolution: {integrity: sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.1': resolution: {integrity: sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==} @@ -2114,6 +2166,15 @@ packages: resolution: {integrity: sha512-B7eicNhAomJ7bGihJO7mCw7pZ8FFo/THQgGPo85VR3FaJVCCot20WxVgvhjc7IVBQVlaaxSrnlUFvA+yHjszqQ==} engines: {node: '>=18'} + '@sentry/api@0.248.0': + resolution: {integrity: sha512-lE9ghOcB3qVbW6PEs51fX01rQzmEaKLPiC+4QLyD8gBQqI4sCLiS7lJefQii7XtvTTn0yAQGky1Qqrf5OQzesQ==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.24.0 + peerDependenciesMeta: + zod: + optional: true + '@sentry/babel-plugin-component-annotate@4.6.1': resolution: {integrity: sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA==} engines: {node: '>= 14'} @@ -2308,24 +2369,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.11': resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.11': resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.11': resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.11': resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==} @@ -3523,24 +3588,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -5135,7 +5204,7 @@ snapshots: '@babel/traverse': 7.28.0 '@babel/types': 7.28.0 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5227,7 +5296,7 @@ snapshots: '@babel/parser': 7.28.0 '@babel/template': 7.27.2 '@babel/types': 7.28.0 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5870,7 +5939,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.12': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.29 '@jridgewell/resolve-uri@3.1.2': {} @@ -5882,7 +5951,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.29': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: @@ -6264,6 +6333,10 @@ snapshots: '@sentry-internal/browser-utils': 10.54.0 '@sentry/core': 10.54.0 + '@sentry/api@0.248.0(zod@3.25.76)': + optionalDependencies: + zod: 3.25.76 + '@sentry/babel-plugin-component-annotate@4.6.1': {} '@sentry/browser@10.54.0': @@ -7678,7 +7751,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.2 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1