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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,11 @@ through the shared request core, `@sentry/toolkit-core/api-client/core`,
which builds them from `readErrorResponse` in
`@sentry/toolkit-core/api-client/response`. The CLI reads raw error
responses with the same helper and maps them to its own exit-coded
`ApiError`. See
`ApiError`. Endpoints that have moved to the shared client live in
`@sentry/toolkit-core/api-client/endpoints/*`; `SentryApiService` calls
them with its Zod schemas, and a malformed response throws
`ApiResponseValidationError` from
`@sentry/toolkit-core/api-client/validate`. See
[`packages/toolkit-core/README.md`](../../packages/toolkit-core/README.md#api-client).

### Automatic Retries
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
"string-width": "^8.2.1",
"tinyglobby": "^0.2.17",
"tsx": "^4.22.4",
"typescript": "^5.9.3",
"typescript": "catalog:",
"uuidv7": "^1.2.1",
"valibot": "^1.4.2",
"vitest": "^4.1.11",
Expand Down
152 changes: 31 additions & 121 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
isAggregateFieldOrEquation,
} from "@sentry/toolkit-core/events-fields";
import {
createSentryClient,
type GetOrganizationIssueEventData,
getOrganizationIssue,
getOrganizationIssueAutofixState,
Expand All @@ -15,13 +14,21 @@ import {
listOrganizationIssues,
listOrganizationProjects,
listOrganizationTeams,
listOrganizations,
listProjectEventAttachments,
parseSentryLinkHeader,
} from "@sentry/api";
import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token";
import type { RetryPolicy } from "@sentry/toolkit-core/api-client/retry";
import { createApiClientCore } from "@sentry/toolkit-core/api-client/core";
import {
type ApiTarget,
createApiClientCore,
listPage,
} from "@sentry/toolkit-core/api-client/core";
import {
getAuthenticatedUser,
getOrganization,
listOrganizations,
} from "@sentry/toolkit-core/api-client/endpoints/organizations";
import { createApiTransport } from "@sentry/toolkit-core/api-client/transport";
import { z } from "zod";
import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants";
Expand Down Expand Up @@ -322,9 +329,6 @@ const REQUEST_RETRY_POLICY: RetryPolicy = {
*/
const transport = createApiTransport({ retry: REQUEST_RETRY_POLICY });

const API_MAX_PER_PAGE = 100;
const MAX_LIST_PAGES = 50;

/**
* Default cap for returning attachment bytes inline over the MCP transport.
* A 30 MB attachment takes ~80 MB in memory once base64-encoded and serialized,
Expand Down Expand Up @@ -859,80 +863,14 @@ export class SentryApiService {
);
}

/** Let SDK operations build paths and queries while MCP owns transport and errors. */
/** Let SDK operations build paths and queries while the core owns transport and errors. */
private sdkClient(expectedPath: string, host: string = this.host) {
const origin = new URL(this.origin(host)).origin;
return createSentryClient({
baseUrl: origin,
fetch: async (sdkRequest) => {
if (!(sdkRequest instanceof Request)) {
throw new ConfigurationError("Unexpected SDK request input");
}
const url = new URL(sdkRequest.url);
if (
url.origin !== origin ||
url.pathname !== "/api/0".concat(expectedPath) ||
sdkRequest.method !== "GET"
) {
throw new ConfigurationError(
"Unexpected SDK request target or method",
);
}
const path = `${url.pathname.slice("/api/0".length)}${url.search}`;
const response = await this.request(
path,
{ method: "GET", signal: sdkRequest.signal },
{ host },
);
const contentType = response.headers.get("content-type");
if (!contentType?.includes("application/json")) {
await readJsonResponse(response);
}
return response;
},
}).client;
return this.core.sdkClient(this.origin(host), expectedPath);
}

private async sdkListPage<T>(
fetchPage: (
perPage: number,
cursor: string | undefined,
) => Promise<{ data: unknown; response: Response }>,
schema: z.ZodType<T[]>,
limit: number,
initialCursor?: string | null,
): Promise<{ items: T[]; nextCursor: string | null }> {
if (
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > API_MAX_PER_PAGE * MAX_LIST_PAGES
) {
throw new ApiValidationError(
`limit must be an integer between 1 and ${API_MAX_PER_PAGE * MAX_LIST_PAGES}`,
);
}
const items: T[] = [];
const seenCursors = new Set<string>();
let cursor = initialCursor ?? undefined;
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
const perPage = Math.min(API_MAX_PER_PAGE, limit - items.length);
const { data, response } = await fetchPage(perPage, cursor);
const rows = schema.parse(data);
if (rows.length > perPage) {
throw new Error("API returned more items than requested");
}
items.push(...rows);
const nextCursor = getNextCursor(response.headers.get("link"));
if (items.length >= limit || !nextCursor) {
return { items, nextCursor };
}
if (seenCursors.has(nextCursor) || nextCursor === cursor) {
throw new Error("API returned a repeated pagination cursor");
}
seenCursors.add(nextCursor);
cursor = nextCursor;
}
throw new Error("API pagination exceeded its page limit");
/** The core and origin a shared endpoint wrapper sends to. */
private target(host?: string): ApiTarget {
return { core: this.core, origin: this.origin(host) };
}

/**
Expand Down Expand Up @@ -1516,24 +1454,16 @@ export class SentryApiService {
/**
* Retrieves the authenticated user's profile information.
*
* @param opts Request options including host override
* @returns User profile data
* @throws {ApiError} If authentication fails or user not found
*/
async getAuthenticatedUser(opts?: RequestOptions): Promise<User> {
// User identity belongs to the deployment's control host.
let authHost: string | undefined;

if (this.isPublicSaas()) {
authHost = "sentry.io";
}
// Single-tenant and self-hosted deployments keep their configured host.

const body = await this.requestJSON("/auth/", undefined, {
...opts,
host: authHost,
});
return UserSchema.parse(body);
async getAuthenticatedUser(): Promise<User> {
// User identity belongs to the deployment's control host. Single-tenant
// and self-hosted deployments keep their configured host.
return await getAuthenticatedUser(
this.target(this.isPublicSaas() ? "sentry.io" : undefined),
UserSchema,
);
}

/**
Expand Down Expand Up @@ -1563,20 +1493,10 @@ export class SentryApiService {
}): Promise<{ organizations: OrganizationList; nextCursor: string | null }> {
// Public SaaS lists across regions on sentry.io; single-tenant instances
// keep organization discovery on their configured host.
const client = this.sdkClient(
"/organizations/",
this.isPublicSaas() ? "sentry.io" : this.host,
);
const { items, nextCursor } = await this.sdkListPage(
(perPage, cursor) =>
listOrganizations({
client,
query: { per_page: perPage, query: params?.query, cursor },
throwOnError: true,
}),
const { items, nextCursor } = await listOrganizations(
this.target(this.isPublicSaas() ? "sentry.io" : this.host),
params ?? {},
OrganizationListSchema,
params?.limit ?? 25,
params?.cursor,
);
return {
organizations: items,
Expand All @@ -1599,21 +1519,11 @@ export class SentryApiService {
params?: { includeFeatureFlags?: boolean; detailed?: boolean },
opts?: RequestOptions,
) {
const queryParams = new URLSearchParams();
if (params?.includeFeatureFlags) {
queryParams.set("include_feature_flags", "1");
}
if (params?.detailed === false) {
queryParams.set("detailed", "0");
}
const queryString = queryParams.toString();
const organizationPath = apiPath`/organizations/${organizationSlug}/`;
const body = await this.requestJSON(
`${organizationPath}${queryString ? `?${queryString}` : ""}`,
undefined,
opts,
return await getOrganization(
this.target(opts?.host),
{ organizationSlug, ...params },
OrganizationSchema,
);
return OrganizationSchema.parse(body);
}

/**
Expand All @@ -1634,7 +1544,7 @@ export class SentryApiService {
): Promise<{ teams: TeamList; nextCursor: string | null }> {
const teamsPath = apiPath`/organizations/${organizationSlug}/teams/`;
const client = this.sdkClient(teamsPath, opts?.host);
const { items, nextCursor } = await this.sdkListPage(
const { items, nextCursor } = await listPage(
(perPage, cursor) =>
listOrganizationTeams({
client,
Expand Down Expand Up @@ -1701,7 +1611,7 @@ export class SentryApiService {
): Promise<{ projects: ProjectList; nextCursor: string | null }> {
const projectsPath = apiPath`/organizations/${organizationSlug}/projects/`;
const client = this.sdkClient(projectsPath, opts?.host);
const { items, nextCursor } = await this.sdkListPage(
const { items, nextCursor } = await listPage(
(perPage, cursor) =>
listOrganizationProjects({
client,
Expand Down
12 changes: 10 additions & 2 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,16 @@ the one exception.
to `<origin>/api/0<path>`, turns network failures into readable errors
(`ConfigurationError` for an unreachable host), and throws the status's
`ApiError` for an error response unless the caller allows that status.
`requestJson` also parses the body. The MCP's `SentryApiService` sends
every request through it.
`requestJson` also parses the body, and `sdkClient` gives a `@sentry/api`
operation a client that sends one GET through `request`. `listPage` walks
a list endpoint's pages up to a limit and validates each page. The MCP's
`SentryApiService` sends every request through the core.
- `endpoints/` holds the endpoint wrappers by domain, on `@sentry/api`
operations where they exist. Each takes an `ApiTarget` (the core and an
origin) and the host's schema, and returns that schema's output. The host
picks the origin, such as the control host for the current user.
`endpoints/organizations` has `getAuthenticatedUser`, `listOrganizations`,
and `getOrganization`.
- `pagination`, `retry`, `transport`, and `response-cache` are described in
the sections below. The `node:zlib` body codec stays outside, at
`response-cache/codec`, since browsers lack it.
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"tsc": "tsc --noEmit && tsc -p tsconfig.browser.json"
},
"dependencies": {
"@sentry/api": "catalog:",
"@sentry/search-syntax": "catalog:",
"@standard-schema/spec": "1.1.0",
"http-cache-semantics": "^4.2.0"
Expand Down
65 changes: 64 additions & 1 deletion packages/toolkit-core/src/api-client/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { type ApiRequestTransport, createApiClientCore } from "./core.js";
import { z } from "zod";
import {
type ApiRequestTransport,
createApiClientCore,
listPage,
} from "./core.js";
import {
ApiNotFoundError,
ApiRateLimitError,
ApiServerError,
ApiValidationError,
ConfigurationError,
} from "./errors.js";
import { ApiResponseValidationError } from "./validate.js";

const ORIGIN = "https://us.sentry.io";

Expand Down Expand Up @@ -248,3 +255,59 @@ describe("createApiClientCore requestJson", () => {
);
});
});

describe("listPage", () => {
const rows = z.array(z.object({ id: z.number() }));

function page(ids: number[], nextCursor?: string) {
const headers: Record<string, string> = nextCursor
? {
link: `<${ORIGIN}/api/0/x/?cursor=${nextCursor}>; rel="next"; results="true"; cursor="${nextCursor}"`,
}
: {};
return {
data: ids.map((id) => ({ id })),
response: new Response(null, { headers }),
};
}

it.each([0, 1.5, 5001])("rejects limit %s", async (limit) => {
await expect(
listPage(async () => page([]), rows, limit),
).rejects.toBeInstanceOf(ApiValidationError);
});

it("rejects a page with more rows than requested", async () => {
await expect(
listPage(async () => page([1, 2, 3]), rows, 2),
).rejects.toThrow("API returned more items than requested");
});

it("rejects a repeated cursor", async () => {
await expect(
listPage(async () => page([1], "0:1:0"), rows, 5),
).rejects.toThrow("API returned a repeated pagination cursor");
});

it("validates each page with the schema", async () => {
await expect(
listPage(
async () => ({ data: [{ id: "x" }], response: new Response(null) }),
rows,
5,
),
).rejects.toBeInstanceOf(ApiResponseValidationError);
});
});

describe("createApiClientCore sdkClient", () => {
it("rejects an SDK request to another path", async () => {
const { core, transport } = setup(async () => jsonResponse([]));
const client = core.sdkClient(ORIGIN, "/organizations/");

await expect(
client.get({ url: "/api/0/projects/", throwOnError: true }),
).rejects.toThrow("Unexpected SDK request target or method");
expect(transport.request).not.toHaveBeenCalled();
});
});
Loading
Loading