diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index 106b6a53e..470a995f7 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -79,11 +79,12 @@ fail to resolve for legacy mixed-case project slugs; keep them for display. Organization discovery uses `/api/0/organizations/`; a limit over 100 follows bounded pages through Sentry's Link cursors. Organization, team, and project -lists use `@sentry/api` operations to build GET paths and queries. The MCP -client still owns the request transport, token handling, retries, error -types, and Zod response validation. Public SaaS hosts use `sentry.io` to list -organizations across regions. Single-tenant hosts under `*.my.sentry.io` and -self-hosted instances keep their configured host for this request. +lists and most issue and event reads use `@sentry/api` operations to build GET +paths and queries. The MCP client still owns the request transport, token +handling, retries, error types, and Zod response validation. Public SaaS +hosts use `sentry.io` to list organizations across regions. Single-tenant +hosts under `*.my.sentry.io` and self-hosted instances keep their configured +host for this request. User identity (`/api/0/auth/`, used by `whoami`) follows the same control-host routing. Organization-scoped requests continue to use the configured host or diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index a5c1264d2..834fa8a3d 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -5,6 +5,7 @@ import { } from "@sentry/mcp-server-mocks"; import { HttpResponse, http } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; import { ConfigurationError } from "../errors"; import { parseSentryUrl } from "../internal/url-helpers"; import { SentryApiService } from "./client"; @@ -2043,6 +2044,36 @@ describe("organization SDK list pages", () => { }); }); +describe("listIssues request", () => { + afterEach(() => mswServer.resetHandlers()); + + it("sends an empty query so Sentry does not apply is:unresolved", async () => { + let params = new URLSearchParams(); + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/issues/", + ({ request }) => { + params = new URL(request.url).searchParams; + return HttpResponse.json([]); + }, + ), + ); + + await new SentryApiService({ accessToken: "test-token" }).listIssues({ + organizationSlug: "test-org", + projectId: "123", + sortBy: "freq", + }); + + expect(params.get("query")).toBe(""); + expect(params.get("limit")).toBe("10"); + expect(params.get("sort")).toBe("freq"); + expect(params.get("statsPeriod")).toBe(DEFAULT_SEARCH_ISSUES_PERIOD); + expect(params.getAll("project")).toEqual(["123"]); + expect(params.getAll("collapse")).toEqual(["unhandled"]); + }); +}); + describe("host configuration", () => { it("should handle hostname without protocol", () => { const apiService = new SentryApiService({ host: "sentry.io" }); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 1184f3b8c..4fb62434e 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -4,9 +4,19 @@ import { } from "@sentry/toolkit-core/events-fields"; import { createSentryClient, + type GetOrganizationIssueEventData, + getOrganizationIssue, + getOrganizationIssueAutofixState, + getOrganizationIssueEvent, + getOrganizationIssueTag, + listOrganizationIssueEvents, + listOrganizationIssueExternalIssues, + listOrganizationIssueIntegrations, + listOrganizationIssues, listOrganizationProjects, listOrganizationTeams, listOrganizations, + listProjectEventAttachments, parseSentryLinkHeader, } from "@sentry/api"; import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; @@ -4090,28 +4100,25 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const sentryQuery: string[] = []; - if (query) { - sentryQuery.push(query); - } - - const queryParams = new URLSearchParams(); - queryParams.set("limit", String(limit)); - if (sortBy) queryParams.set("sort", sortBy); - queryParams.set("statsPeriod", statsPeriod ?? DEFAULT_SEARCH_ISSUES_PERIOD); - queryParams.set("query", sentryQuery.join(" ")); - - if (projectId) { - queryParams.append("project", projectId); - } - queryParams.append("collapse", "unhandled"); - - const apiUrl = - apiPath`/organizations/${organizationSlug}/issues/` + - `?${queryParams.toString()}`; - - const body = await this.requestJSON(apiUrl, undefined, opts); - return IssueListSchema.parse(body); + const { data } = await listOrganizationIssues({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/`, + opts?.host, + ), + path: { organization_id_or_slug: organizationSlug }, + query: { + limit, + sort: sortBy, + statsPeriod: statsPeriod ?? DEFAULT_SEARCH_ISSUES_PERIOD, + // Always send `query`, even empty: Sentry applies `is:unresolved` + // only when the parameter is missing. + query: query ?? "", + project: projectId ? [projectId] : undefined, + collapse: ["unhandled"], + }, + throwOnError: true, + }); + return IssueListSchema.parse(data); } async getIssue( @@ -4124,12 +4131,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/`, - undefined, - opts, - ); - return IssueSchema.parse(body); + const { data } = await getOrganizationIssue({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/`, + opts?.host, + ), + path: { organization_id_or_slug: organizationSlug, issue_id: issueId }, + throwOnError: true, + }); + return IssueSchema.parse(data); } /** @@ -4169,12 +4179,19 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/tags/${tagKey}/`, - undefined, - opts, - ); - return IssueTagValuesSchema.parse(body); + const { data } = await getOrganizationIssueTag({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/tags/${tagKey}/`, + opts?.host, + ), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + key: tagKey, + }, + throwOnError: true, + }); + return IssueTagValuesSchema.parse(data); } /** @@ -4196,10 +4213,23 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - return await this.listIssueLinkPages( + const client = this.sdkClient( apiPath`/organizations/${organizationSlug}/issues/${issueId}/external-issues/`, + opts?.host, + ); + // The endpoint has no per_page parameter; Sentry's default page is 100. + return await this.collectAllPages( + (cursor) => + listOrganizationIssueExternalIssues({ + client, + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + query: { cursor }, + throwOnError: true, + }), ExternalIssueListSchema, - opts, ); } @@ -4208,9 +4238,7 @@ export class SentryApiService { schema: z.ZodType, opts?: RequestOptions, ): Promise { - const items: T[] = []; - let cursor: string | null = null; - do { + return await this.collectAllPages(async (cursor) => { const query = new URLSearchParams({ per_page: "100" }); if (cursor) query.set("cursor", cursor); const response = await this.request( @@ -4218,8 +4246,23 @@ export class SentryApiService { undefined, opts, ); - items.push(...schema.parse(await this.parseJsonResponse(response))); - cursor = getNextCursor(response.headers.get("link")); + return { data: await this.parseJsonResponse(response), response }; + }, schema); + } + + /** Walks every page of a Link-paginated list. */ + private async collectAllPages( + fetchPage: ( + cursor: string | undefined, + ) => Promise<{ data: unknown; response: Response }>, + schema: z.ZodType, + ): Promise { + const items: T[] = []; + let cursor: string | undefined; + do { + const { data, response } = await fetchPage(cursor); + items.push(...schema.parse(data)); + cursor = getNextCursor(response.headers.get("link")) ?? undefined; } while (cursor); return items; } @@ -4231,10 +4274,22 @@ export class SentryApiService { }: { organizationSlug: string; issueId: string }, opts?: RequestOptions, ): Promise { - return await this.listIssueLinkPages( + const client = this.sdkClient( apiPath`/organizations/${organizationSlug}/issues/${issueId}/integrations/`, + opts?.host, + ); + return await this.collectAllPages( + (cursor) => + listOrganizationIssueIntegrations({ + client, + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + }, + query: { per_page: 100, cursor }, + throwOnError: true, + }), IssueIntegrationListSchema, - opts, ); } @@ -4454,15 +4509,23 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - // `collapse=adjacentEvents` lets Sentry skip the next/previous-event - // lookups; no tool reads `nextEventID` or `previousEventID`. Servers - // without the option ignore unknown collapse values. - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/` + - "?collapse=adjacentEvents", - undefined, - opts, - ); + // @sentry/api sends `collapse=adjacentEvents` by default, so Sentry skips + // the next/previous-event lookups; no tool reads `nextEventID` or + // `previousEventID`. + const { data: body }: { data: unknown } = await getOrganizationIssueEvent({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/`, + opts?.host, + ), + path: { + organization_id_or_slug: organizationSlug, + issue_id: issueId, + // The generated type lists only the aliases, but Sentry also accepts + // a 32-character event ID here. + event_id: eventId as GetOrganizationIssueEventData["path"]["event_id"], + }, + throwOnError: true, + }); // Try to parse with known event schemas first const parseResult = EventSchema.safeParse(body); @@ -4621,7 +4684,6 @@ export class SentryApiService { issueId, query, limit = 50, - sort, statsPeriod, start, end, @@ -4631,41 +4693,29 @@ export class SentryApiService { issueId: string; query?: string; limit?: number; - sort?: string; statsPeriod?: string; start?: string; end?: string; full?: boolean; }, opts?: RequestOptions, - ) { - const params = new URLSearchParams(); - - if (query) { - params.append("query", query); - } - - params.append("per_page", String(limit)); - - if (sort) { - params.append("sort", sort); - } - - if (statsPeriod) { - params.append("statsPeriod", statsPeriod); - } else if (start && end) { - params.append("start", start); - params.append("end", end); - } - - if (full) { - params.append("full", "true"); - } - - const apiUrl = - apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/` + - `?${params.toString()}`; - return await this.requestJSON(apiUrl, undefined, opts); + ): Promise { + // Sentry always returns these newest first; the endpoint has no sort. + const { data } = await listOrganizationIssueEvents({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/events/`, + opts?.host, + ), + path: { organization_id_or_slug: organizationSlug, issue_id: issueId }, + query: { + query: query || undefined, + per_page: limit, + ...(statsPeriod ? { statsPeriod } : start && end ? { start, end } : {}), + full: full || undefined, + }, + throwOnError: true, + }); + return data; } async listEventAttachments( @@ -4680,12 +4730,19 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`, - undefined, - opts, - ); - return EventAttachmentListSchema.parse(body); + const { data } = await listProjectEventAttachments({ + client: this.sdkClient( + apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`, + opts?.host, + ), + path: { + organization_id_or_slug: organizationSlug, + project_id_or_slug: projectSlug, + event_id: eventId, + }, + throwOnError: true, + }); + return EventAttachmentListSchema.parse(data); } async getEventAttachment( @@ -4723,13 +4780,10 @@ export class SentryApiService { contentType: string; }> { // Get the attachment metadata first - const attachmentsData = await this.requestJSON( - apiPath`/projects/${organizationSlug}/${projectSlug}/events/${eventId}/attachments/`, - undefined, + const attachments = await this.listEventAttachments( + { organizationSlug, projectSlug, eventId }, opts, ); - - const attachments = EventAttachmentListSchema.parse(attachmentsData); const attachment = attachments.find((att) => att.id === attachmentId); if (!attachment) { @@ -5468,12 +5522,15 @@ export class SentryApiService { }, opts?: RequestOptions, ): Promise { - const body = await this.requestJSON( - apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/`, - undefined, - opts, - ); - return AutofixRunStateSchema.parse(body); + const { data } = await getOrganizationIssueAutofixState({ + client: this.sdkClient( + apiPath`/organizations/${organizationSlug}/issues/${issueId}/autofix/`, + opts?.host, + ), + path: { organization_id_or_slug: organizationSlug, issue_id: issueId }, + throwOnError: true, + }); + return AutofixRunStateSchema.parse(data); } // POST https://us.sentry.io/api/0/organizations/my-org/search-agent/start/ 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 c5cd6ab37..feb057086 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 @@ -169,7 +169,6 @@ describe("search_issue_events", () => { ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("query")).toBe("environment:production"); - expect(url.searchParams.get("sort")).toBe("-timestamp"); return HttpResponse.json([]); }, ), @@ -210,7 +209,6 @@ describe("search_issue_events", () => { ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("query")).toBe("environment:production"); - expect(url.searchParams.get("sort")).toBe("-timestamp"); return HttpResponse.json([]); }, ), @@ -232,6 +230,7 @@ describe("search_issue_events", () => { ); expect(result).toContain("No results found"); + expect(result).toContain("sort=-timestamp"); }); it("should include user geo details in formatted event output", async () => { @@ -773,7 +772,6 @@ describe("search_issue_events", () => { ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("query")).toBe("environment:production"); - expect(url.searchParams.get("sort")).toBe("-timestamp"); expect(url.searchParams.get("statsPeriod")).toBe("7d"); return HttpResponse.json([ { diff --git a/packages/mcp-core/src/tools/catalog/search-issue-events.ts b/packages/mcp-core/src/tools/catalog/search-issue-events.ts index 1bba0b92e..60d27e72b 100644 --- a/packages/mcp-core/src/tools/catalog/search-issue-events.ts +++ b/packages/mcp-core/src/tools/catalog/search-issue-events.ts @@ -239,7 +239,6 @@ export default defineTool({ issueId, query, limit: params.limit, - sort: sortParam, ...timeParams, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32f73fd4d..ed6df3fd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 0.420.0 - version: 0.420.0 + specifier: 0.423.0 + version: 0.423.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -467,7 +467,7 @@ importers: version: 1.46.0(ai@6.0.230(zod@4.6.7))(express@5.3.0)(zod@4.6.7) '@sentry/api': specifier: 'catalog:' - version: 0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.423.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -801,7 +801,7 @@ importers: version: 2.3.0 '@sentry/api': specifier: 'catalog:' - version: 0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 0.423.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -4317,8 +4317,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@0.420.0': - resolution: {integrity: sha512-s5FOQbBy9qVdZGvZrBIfjJEInXPRW+mqWXFi/2qLqzqPY3tv0YD9tQDGozjShO03AOGV3WwHrUdIIcgFMOZ7vw==} + '@sentry/api@0.423.0': + resolution: {integrity: sha512-uJSIvgMgijEizlEkTDs4fegnOrm2RWjkVRT34ofKOGFKN0z8S/iIvsLvycJSL4BRjMHKA1KspbkWy+B0GKZqpA==} engines: {node: '>=22'} peerDependencies: valibot: '*' @@ -12508,7 +12508,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@0.420.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@0.423.0(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': optionalDependencies: valibot: 1.4.2(typescript@5.9.3) zod: 4.6.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 86bda1339..3c8e14cd1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -51,7 +51,7 @@ catalog: "@modelcontextprotocol/server": 2.3.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 0.420.0 + "@sentry/api": 0.423.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -163,6 +163,6 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@0.420.0" + - "@sentry/api@0.423.0" - miniflare@5.20261019.1-beta - wrangler@4.151.3