diff --git a/package.json b/package.json index d26fc15a..0fa9559c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.15.1", "engines": { - "node": ">=20" + "node": ">=22" }, "license": "FSL-1.1-ALv2", "author": "Sentry", diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 74996797..f28cf314 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -5,15 +5,13 @@ "private": true, "packageManager": "pnpm@10.8.1", "engines": { - "node": ">=20" + "node": ">=22" }, "license": "FSL-1.1-ALv2", "author": "Sentry", "description": "Sentry MCP Core - Shared code for MCP transports", "homepage": "https://github.com/getsentry/sentry-mcp", - "keywords": [ - "sentry" - ], + "keywords": ["sentry"], "bugs": { "url": "https://github.com/getsentry/sentry-mcp/issues" }, @@ -21,9 +19,7 @@ "type": "git", "url": "git@github.com:getsentry/sentry-mcp.git" }, - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { "./api-client": { "types": "./dist/api-client/index.d.ts", @@ -168,6 +164,7 @@ "@logtape/logtape": "^1.1.1", "@logtape/sentry": "^1.1.1", "@modelcontextprotocol/sdk": "catalog:", + "@sentry/api": "^0.228.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 c2ad4c6a..9e7d7258 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,15 +1231,11 @@ 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"), - expect.any(Object), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); + expect(url).toContain("dataset=errors"); + expect(url).toContain("sort=-count"); }); it("should route spans dataset to EAP API builder with sampling", async () => { @@ -1225,15 +1244,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", @@ -1242,15 +1253,11 @@ 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), + const url = extractFetchUrl( + (globalThis.fetch as ReturnType).mock.calls[0], ); + expect(url).toContain("dataset=spans"); + expect(url).toContain("sampling=NORMAL"); }); it("should normalize metrics dataset to tracemetrics for Discover queries", async () => { @@ -1259,14 +1266,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", @@ -1279,15 +1279,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)", ); }); @@ -1297,14 +1296,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", @@ -1315,12 +1307,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, + ); + } }); }); @@ -1332,41 +1538,47 @@ describe("API query builders", () => { }); const urls: string[] = []; - globalThis.fetch = vi.fn().mockImplementation((url: string) => { - urls.push(url); - const requestUrl = new URL(url); - const attributeType = requestUrl.searchParams.get("attributeType"); - const body = - attributeType === "boolean" - ? [ - { - key: "tags[enabled,boolean]", - name: "enabled", - attributeType: "boolean", - attributeSource: { source_type: "user" }, - }, - ] - : [ - { - key: "tags[type]", - name: "type", - attributeType: "string", - attributeSource: { - source_type: "sentry", - is_transformed_alias: true, + globalThis.fetch = vi + .fn() + .mockImplementation((input: string | Request) => { + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : String(input); + urls.push(url); + const requestUrl = new URL(url); + const attributeType = requestUrl.searchParams.get("attributeType"); + const body = + attributeType === "boolean" + ? [ + { + key: "tags[enabled,boolean]", + name: "enabled", + attributeType: "boolean", + attributeSource: { source_type: "user" }, }, - }, - ]; - - return Promise.resolve({ - ok: true, - headers: { - get: (key: string) => - key === "content-type" ? "application/json" : null, - }, - json: () => Promise.resolve(body), + ] + : [ + { + key: "tags[type]", + name: "type", + attributeType: "string", + attributeSource: { + source_type: "sentry", + is_transformed_alias: true, + }, + }, + ]; + + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); }); - }); const result = await apiService.listTraceItemAttributes({ organizationSlug: "test-org", diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 9c4ecb23..16f608fc 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1,5 +1,55 @@ +import { + parseSentryLinkHeader, + addProjectTeam as sdkAddATeamToAProject, + createProjectKey as sdkCreateANewClientKey, + createTeamProject as sdkCreateANewProject, + createOrganizationTeam as sdkCreateANewTeam, + listProjectKeys as sdkListAProjectSClientKeys, + listProjectIssues as sdkListAProjectSIssues, + 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, getAIConversationUrl as getAIConversationUrlUtil, getDashboardUrl as getDashboardUrlUtil, @@ -14,83 +64,77 @@ 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, - AIConversationSpanListSchema, + SpansSearchResponseSchema, + TagListSchema, + TraceMetaSchema, + TraceSchema, + TeamListSchema, + TeamSchema, + TransactionProfileSchema, + UserRegionsSchema, + UserSchema, } 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, @@ -99,33 +143,28 @@ 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, - AIConversationSpanList, + User, } 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) @@ -177,22 +216,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; } /** @@ -274,12 +300,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; } @@ -411,6 +438,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). * @@ -1357,19 +1465,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 @@ -1385,12 +1488,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)) @@ -1403,8 +1513,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 @@ -1420,12 +1534,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); } /** @@ -1442,16 +1556,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); } /** @@ -1474,15 +1588,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); } /** @@ -1499,16 +1611,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( @@ -1589,12 +1702,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); } /** @@ -1622,21 +1738,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); } /** @@ -1667,21 +1781,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( @@ -2004,14 +2117,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"); } /** @@ -2048,17 +2162,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); } /** @@ -2080,12 +2193,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); } /** @@ -2124,21 +2240,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( @@ -2461,29 +2582,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( @@ -2512,44 +2624,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; } /** @@ -2682,24 +2795,36 @@ export class SentryApiService { query?: string, opts?: RequestOptions, ): Promise { - const queryParams = new URLSearchParams(); - queryParams.set("itemType", itemType); - queryParams.set("attributeType", attributeType); + const queryParams: Record = { + itemType, + attributeType: [attributeType], + }; 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, attributeType); + const result = await sdkListTraceItemAttributes({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: queryParams, + } as Parameters[0]); + const data = this.unwrapSdkResult( + result, + `listTraceItemAttributes(${attributeType})`, + ); + return parseTraceItemAttributes(data, attributeType); } /** @@ -2755,20 +2880,40 @@ export class SentryApiService { sentryQuery.push(query); } - const queryParams = new URLSearchParams(); - queryParams.set("limit", String(limit)); - if (sortBy) queryParams.set("sort", sortBy); - queryParams.set("statsPeriod", "24h"); - queryParams.set("query", sentryQuery.join(" ")); - - queryParams.append("collapse", "unhandled"); - - const apiUrl = projectSlug - ? `/projects/${organizationSlug}/${projectSlug}/issues/?${queryParams.toString()}` - : `/organizations/${organizationSlug}/issues/?${queryParams.toString()}`; + if (projectSlug) { + // The SDK type doesn't include per_page, sort, or collapse query params, + // so we pass them via cast. + const result = await sdkListAProjectSIssues({ + ...this.getSdkConfig(opts), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + }, + query: { + per_page: limit, + sort: sortBy, + statsPeriod: "24h", + query: sentryQuery.join(" "), + collapse: ["unhandled"], + }, + } as Parameters[0]); + const data = this.unwrapSdkResult(result, "listIssues(project)"); + return IssueListSchema.parse(data); + } - const body = await this.requestJSON(apiUrl, undefined, opts); - return IssueListSchema.parse(body); + const result = await sdkListAnOrganizationSIssues({ + ...this.getSdkConfig(opts), + path: { organization_id_or_slug: organizationSlug }, + query: { + limit, + sort: sortBy, + statsPeriod: "24h", + query: sentryQuery.join(" "), + collapse: ["unhandled"], + }, + }); + const data = this.unwrapSdkResult(result, "listIssues"); + return IssueListSchema.parse(data); } async getIssue( @@ -2781,12 +2926,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); } /** @@ -2826,12 +2974,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); } /** @@ -2856,12 +3008,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( @@ -2876,11 +3031,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); @@ -2997,31 +3156,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( @@ -3036,12 +3198,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( @@ -3064,14 +3230,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) { @@ -3080,7 +3244,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, @@ -3115,12 +3280,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( @@ -3136,20 +3304,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] ?? []; } @@ -3165,12 +3337,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( @@ -3199,16 +3377,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; @@ -3223,15 +3394,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( @@ -3349,28 +3521,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( @@ -3400,30 +3566,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; } // ================================================================================ @@ -3598,6 +3763,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); @@ -3606,7 +3773,6 @@ export class SentryApiService { normalizedDataset === "tracemetrics" || normalizedDataset === "profiles" ) { - // Use Discover API query builder queryParams = this.buildDiscoverApiQuery({ query, fields, @@ -3619,7 +3785,6 @@ export class SentryApiService { sort, }); } else { - // Use EAP API query builder for spans and logs queryParams = this.buildEapApiQuery({ query, fields, @@ -3633,8 +3798,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/ @@ -3652,18 +3833,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/ @@ -3677,12 +3859,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); } /** @@ -3719,15 +3904,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); } /** @@ -3771,19 +3957,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); } async getAIConversation( @@ -3880,21 +4069,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( @@ -3909,9 +4101,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); } /** @@ -3965,14 +4164,21 @@ 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, + // The SDK types `project` as a scalar number (the spec under-types it + // vs the sibling flamegraph endpoint, which accepts IDs or slugs). + // Forward projectId as-is so slug lookups keep working; Number() would + // turn a slug into NaN. The value serializes to the query string either way. + project: projectId as number, + start, + end, + }, + }); + const body = this.unwrapSdkResult(result, "getProfileChunk"); // Response wraps chunks in {chunks: []} 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 d80fee97..26bf5f71 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.ts b/packages/mcp-core/src/api-client/schema.ts index ca2392a0..b28cbfc0 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. @@ -654,13 +660,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, @@ -1045,14 +1046,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=sentry-mcp&sort=-timestamp&statsPeriod=1w export const ErrorsSearchResponseSchema = EventsResponseSchema.extend({ @@ -1086,15 +1086,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([ @@ -1215,18 +1213,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/azure-openai-provider.test.ts b/packages/mcp-core/src/internal/agents/azure-openai-provider.test.ts index befa4435..eefbed1f 100644 --- a/packages/mcp-core/src/internal/agents/azure-openai-provider.test.ts +++ b/packages/mcp-core/src/internal/agents/azure-openai-provider.test.ts @@ -9,40 +9,11 @@ import { } from "./azure-openai-provider.js"; describe("azure-openai-provider", () => { - const originalModel = process.env.OPENAI_MODEL; - const originalApiKey = process.env.OPENAI_API_KEY; - const originalApiVersion = process.env.OPENAI_API_VERSION; - beforeEach(() => { setAzureOpenAIBaseUrl(undefined); - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_MODEL; - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_VERSION; }); afterEach(() => { - if (originalModel === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_MODEL; - } else { - process.env.OPENAI_MODEL = originalModel; - } - - if (originalApiKey === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = originalApiKey; - } - - if (originalApiVersion === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_VERSION; - } else { - process.env.OPENAI_API_VERSION = originalApiVersion; - } - vi.unstubAllGlobals(); }); 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 9730a055..dbf33244 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/internal/agents/openai-provider.test.ts b/packages/mcp-core/src/internal/agents/openai-provider.test.ts index 62ea23b6..02d5a0fd 100644 --- a/packages/mcp-core/src/internal/agents/openai-provider.test.ts +++ b/packages/mcp-core/src/internal/agents/openai-provider.test.ts @@ -4,40 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getOpenAIModel, setOpenAIBaseUrl } from "./openai-provider.js"; describe("openai-provider", () => { - const originalModel = process.env.OPENAI_MODEL; - const originalApiKey = process.env.OPENAI_API_KEY; - const originalApiVersion = process.env.OPENAI_API_VERSION; - beforeEach(() => { setOpenAIBaseUrl(undefined); - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_MODEL; - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_VERSION; }); afterEach(() => { - if (originalModel === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_MODEL; - } else { - process.env.OPENAI_MODEL = originalModel; - } - - if (originalApiKey === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = originalApiKey; - } - - if (originalApiVersion === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_VERSION; - } else { - process.env.OPENAI_API_VERSION = originalApiVersion; - } - vi.unstubAllGlobals(); }); diff --git a/packages/mcp-core/src/internal/agents/provider-factory.test.ts b/packages/mcp-core/src/internal/agents/provider-factory.test.ts index 1e0c8799..82630289 100644 --- a/packages/mcp-core/src/internal/agents/provider-factory.test.ts +++ b/packages/mcp-core/src/internal/agents/provider-factory.test.ts @@ -8,43 +8,13 @@ import { setAzureOpenAIBaseUrl } from "./azure-openai-provider.js"; import { ConfigurationError } from "../../errors.js"; describe("provider-factory", () => { - const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; - const originalOpenAIKey = process.env.OPENAI_API_KEY; - const originalProviderEnv = process.env.EMBEDDED_AGENT_PROVIDER; - beforeEach(() => { // Reset module state setAgentProvider(undefined); setAzureOpenAIBaseUrl(undefined); - // Clear environment variables - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.ANTHROPIC_API_KEY; - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_KEY; - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.EMBEDDED_AGENT_PROVIDER; }); afterEach(() => { - // Restore original environment - if (originalAnthropicKey === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = originalAnthropicKey; - } - if (originalOpenAIKey === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = originalOpenAIKey; - } - if (originalProviderEnv === undefined) { - // biome-ignore lint/performance/noDelete: Required to properly unset environment variable - delete process.env.EMBEDDED_AGENT_PROVIDER; - } else { - process.env.EMBEDDED_AGENT_PROVIDER = originalProviderEnv; - } setAgentProvider(undefined); setAzureOpenAIBaseUrl(undefined); }); diff --git a/packages/mcp-core/src/test-setup.ts b/packages/mcp-core/src/test-setup.ts index 586b561c..9187b02f 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/get-issue-tag-values.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-tag-values.test.ts index 918bbfc4..cc35a7da 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/get-sentry-resource.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts index f8d4e925..2c0fdbc0 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts @@ -10,13 +10,9 @@ import { } from "@sentry/mcp-server-mocks"; import { encode as encodePng } from "fast-png"; import { http, HttpResponse } from "msw"; -import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import getSentryResource from "./get-sentry-resource.js"; -const originalOpenAIApiKey = process.env.OPENAI_API_KEY; -const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; -const originalEmbeddedAgentProvider = process.env.EMBEDDED_AGENT_PROVIDER; - const baseContext = { constraints: { organizationSlug: undefined, @@ -114,32 +110,6 @@ function mockMonitorResource({ } describe("get_sentry_resource", () => { - beforeEach(() => { - Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); - }); - - afterAll(() => { - if (originalOpenAIApiKey === undefined) { - Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); - } else { - process.env.OPENAI_API_KEY = originalOpenAIApiKey; - } - - if (originalAnthropicApiKey === undefined) { - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - } else { - process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey; - } - - if (originalEmbeddedAgentProvider === undefined) { - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); - } else { - process.env.EMBEDDED_AGENT_PROVIDER = originalEmbeddedAgentProvider; - } - }); - // ─── URL mode: issue URLs ────────────────────────────────────────────────── describe("URL mode — issue URLs", () => { it("resolves issue from subdomain URL (my-org.sentry.io)", async () => { diff --git a/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts b/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts index 0882fd0d..dfc3fd33 100644 --- a/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-trace-details.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { mswServer, @@ -9,10 +9,6 @@ import { } from "@sentry/mcp-server-mocks"; import getTraceDetails from "./get-trace-details.js"; -const originalOpenAIApiKey = process.env.OPENAI_API_KEY; -const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; -const originalEmbeddedAgentProvider = process.env.EMBEDDED_AGENT_PROVIDER; - /** Register the same handler on sentry.io and us.sentry.io (org fixture resolves region). */ function httpGetRegional( sentryIoUrl: string, @@ -105,28 +101,6 @@ function buildTraceSpanNode({ describe("get_trace_details", () => { beforeEach(() => { process.env.OPENAI_API_KEY = "test-key"; - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); - }); - - afterAll(() => { - if (originalOpenAIApiKey === undefined) { - Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); - } else { - process.env.OPENAI_API_KEY = originalOpenAIApiKey; - } - - if (originalAnthropicApiKey === undefined) { - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - } else { - process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey; - } - - if (originalEmbeddedAgentProvider === undefined) { - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); - } else { - process.env.EMBEDDED_AGENT_PROVIDER = originalEmbeddedAgentProvider; - } }); it("serializes with valid trace ID", async () => { @@ -240,9 +214,9 @@ describe("get_trace_details", () => { }); it("falls back to direct search_events guidance when agent search is unavailable", async () => { - Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + process.env.EMBEDDED_AGENT_PROVIDER = ""; const result = await getTraceDetails.handler( { @@ -265,9 +239,9 @@ describe("get_trace_details", () => { }); it("does not show trace next-step tool calls when search_events is unavailable", async () => { - Reflect.deleteProperty(process.env, "OPENAI_API_KEY"); - Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY"); - Reflect.deleteProperty(process.env, "EMBEDDED_AGENT_PROVIDER"); + process.env.OPENAI_API_KEY = ""; + process.env.ANTHROPIC_API_KEY = ""; + process.env.EMBEDDED_AGENT_PROVIDER = ""; const result = await getTraceDetails.handler( { 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 a9304080..706e40b4 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 { generateText } from "ai"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserInputError } from "../../errors"; +import searchEvents from "./search-events"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { 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 11e70436..0ab92d1e 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", () => { @@ -560,9 +560,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 c3cbe63e..7e769b01 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -427,6 +427,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 271b2b49..26929dd9 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 @@ -258,7 +258,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( @@ -281,7 +281,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"); @@ -359,9 +359,10 @@ describe("fetchCustomAttributes", () => { ), ); + // The SDK wraps network errors — the context prefix is added by unwrapSdkResult await expect( fetchCustomAttributes(apiService, "test-org", "spans"), - ).rejects.toThrow("Network error: ETIMEDOUT"); + ).rejects.toThrow("listTraceItemAttributes(string):"); }); }); diff --git a/packages/mcp-server-evals/package.json b/packages/mcp-server-evals/package.json index 5d5d3a14..391c0021 100644 --- a/packages/mcp-server-evals/package.json +++ b/packages/mcp-server-evals/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20" + "node": ">=22" }, "license": "FSL-1.1-ALv2", "scripts": { diff --git a/packages/mcp-server-mocks/package.json b/packages/mcp-server-mocks/package.json index 9c4c27d0..56b5b661 100644 --- a/packages/mcp-server-mocks/package.json +++ b/packages/mcp-server-mocks/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20" + "node": ">=22" }, "license": "FSL-1.1-ALv2", "exports": { diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 113c23f1..f64f664b 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -1187,8 +1187,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/packages/mcp-server/package.json b/packages/mcp-server/package.json index 6678d1a9..96c0c330 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.8.1", "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public" @@ -13,11 +13,7 @@ "license": "FSL-1.1-ALv2", "author": "Sentry", "homepage": "https://github.com/getsentry/sentry-mcp", - "keywords": [ - "sentry", - "mcp", - "model-context-protocol" - ], + "keywords": ["sentry", "mcp", "model-context-protocol"], "bugs": { "url": "https://github.com/getsentry/sentry-mcp/issues" }, @@ -28,9 +24,7 @@ "bin": { "sentry-mcp": "./dist/index.js" }, - "files": [ - "./dist/*" - ], + "files": ["./dist/*"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1574dbc7..d4815bbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -421,6 +421,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.228.0 + version: 0.228.0(zod@3.25.76) '@sentry/core': specifier: 'catalog:' version: 10.54.0 @@ -2327,6 +2330,15 @@ packages: resolution: {integrity: sha512-B7eicNhAomJ7bGihJO7mCw7pZ8FFo/THQgGPo85VR3FaJVCCot20WxVgvhjc7IVBQVlaaxSrnlUFvA+yHjszqQ==} engines: {node: '>=18'} + '@sentry/api@0.228.0': + resolution: {integrity: sha512-FiFqvicH/Uv/dAp0R1wMDrtd06WfrT26DUPKEKuVbD//qKzcrC/puBdwMcRaHpMGRZRg3XMASxZQpXz5ERX4Wg==} + 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'} @@ -7665,6 +7677,10 @@ snapshots: '@sentry-internal/browser-utils': 10.54.0 '@sentry/core': 10.54.0 + '@sentry/api@0.228.0(zod@3.25.76)': + optionalDependencies: + zod: 3.25.76 + '@sentry/babel-plugin-component-annotate@4.6.1': {} '@sentry/browser@10.54.0':