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
11 changes: 7 additions & 4 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,13 @@ fail to resolve for legacy mixed-case project slugs; keep them for display.

### Multi-Region Support

Organization discovery uses a single `/api/0/organizations/` request. 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.
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, GET 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
Expand Down
182 changes: 171 additions & 11 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfigurationError } from "../errors";
import { parseSentryUrl } from "../internal/url-helpers";
import { SentryApiService } from "./client";
import { ApiNotFoundError, ApiServerError } from "./errors";
import { ApiNotFoundError, ApiPermissionError, ApiServerError } from "./errors";

describe("API bearer token validation", () => {
it("removes edge padding before sending a request", async () => {
Expand Down Expand Up @@ -1373,6 +1373,47 @@ describe("listOrganizations", () => {
globalThis.fetch = originalFetch;
});

it("collects a requested page larger than the API maximum without skipping its next cursor", async () => {
const pageSizes: number[] = [];
const cursors: (string | null)[] = [];
mswServer.use(
http.get("https://sentry.io/api/0/organizations/", ({ request }) => {
const url = new URL(request.url);
const perPage = Number(url.searchParams.get("per_page"));
const cursor = url.searchParams.get("cursor");
pageSizes.push(perPage);
cursors.push(cursor);
if (perPage > 100) {
return HttpResponse.json(
{ detail: "Invalid per_page" },
{ status: 400 },
);
}
const start = cursor === null ? 0 : Number(cursor);
return HttpResponse.json(
Array.from({ length: perPage }, (_, offset) => ({
id: String(start + offset),
slug: `org-${start + offset}`,
name: `Org ${start + offset}`,
})),
{
headers: {
Link: `<https://sentry.io/api/0/organizations/?cursor=${start + perPage}>; rel="next"; results="true"; cursor="${start + perPage}"`,
},
},
);
}),
);

const api = new SentryApiService({ host: "sentry.io" });
const result = await api.listOrganizations({ limit: 205 });
expect(result.organizations).toHaveLength(205);
expect(result.organizations[204]?.slug).toBe("org-204");
expect(result.nextCursor).toBe("205");
expect(pageSizes).toEqual([100, 100, 5]);
expect(cursors).toEqual([null, "100", "200"]);
});

it("should fetch from the organizations endpoint on the root host for SaaS", async () => {
const mockOrgs = [
{ id: "1", slug: "org-us", name: "Org US" },
Expand All @@ -1383,11 +1424,7 @@ describe("listOrganizations", () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes("/organizations/")) {
return Promise.resolve({
ok: true,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(mockOrgs),
});
return Promise.resolve(HttpResponse.json(mockOrgs));
}
return Promise.reject(new Error("Unexpected URL"));
});
Expand Down Expand Up @@ -1425,11 +1462,7 @@ describe("listOrganizations", () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes("/organizations/")) {
return Promise.resolve({
ok: true,
headers: new Headers({ "content-type": "application/json" }),
json: () => Promise.resolve(mockOrgs),
});
return Promise.resolve(HttpResponse.json(mockOrgs));
}
return Promise.reject(new Error("Unexpected URL"));
});
Expand All @@ -1453,6 +1486,133 @@ describe("listOrganizations", () => {
});
});

describe("organization SDK list pages", () => {
it.each([
{
resource: "teams",
fixture: teamFixture,
list: (api: SentryApiService) =>
api.listTeams(
"my-org",
{ limit: 201, query: "api" },
{ host: "de.sentry.io" },
),
},
{
resource: "projects",
fixture: projectFixture,
list: (api: SentryApiService) =>
api.listProjects(
"my-org",
{ limit: 201, query: "api" },
{ host: "de.sentry.io" },
),
},
])(
"caps each $resource page and retains the regional host",
async ({ resource, fixture, list }) => {
const pageSizes: number[] = [];
mswServer.use(
http.get(
`https://de.sentry.io/api/0/organizations/my-org/${resource}/`,
({ request }) => {
const url = new URL(request.url);
const perPage = Number(url.searchParams.get("per_page"));
pageSizes.push(perPage);
expect(url.searchParams.get("query")).toBe("api");
expect(request.headers.get("authorization")).toBe(
"Bearer test-token",
);
if (perPage > 100) {
return HttpResponse.json(
{ detail: "Invalid per_page" },
{ status: 400 },
);
}
const start = Number(url.searchParams.get("cursor") ?? "0");
return HttpResponse.json(
Array.from({ length: perPage }, (_, offset) => ({
...fixture,
id: String(start + offset),
})),
{
headers: {
Link: `<https://de.sentry.io/api/0/organizations/my-org/${resource}/?cursor=${start + perPage}>; rel="next"; results="true"; cursor="${start + perPage}"`,
},
},
);
},
),
);
const api = new SentryApiService({ accessToken: "test-token" });
const result = await list(api);
const rows = "teams" in result ? result.teams : result.projects;
expect(rows).toHaveLength(201);
expect(result.nextCursor).toBe("201");
expect(pageSizes).toEqual([100, 100, 1]);
},
);

it("rejects dot-segment organization IDs before an SDK request", async () => {
const api = new SentryApiService({ host: "sentry.example.com" });
await expect(api.listProjects("..", { limit: 1 })).rejects.toThrow();
await expect(api.listTeams(".", { limit: 1 })).rejects.toThrow();
});

it("confines an organization slug with a slash to one path segment", async () => {
const paths: string[] = [];
mswServer.use(
http.get("https://sentry.io/api/0/*", ({ request }) => {
paths.push(new URL(request.url).pathname);
return HttpResponse.json([teamFixture]);
}),
);

const api = new SentryApiService({ host: "sentry.io" });
const result = await api.listTeams("my/org");
expect(result.teams).toHaveLength(1);
expect(paths).toEqual(["/api/0/organizations/my%2Forg/teams/"]);
});

it("keeps MCP's HTTP error class and details when an SDK list fails", async () => {
mswServer.use(
http.get("https://sentry.io/api/0/organizations/my-org/teams/", () =>
HttpResponse.json({ detail: "Team access denied" }, { status: 403 }),
),
);
const api = new SentryApiService({ host: "sentry.io" });
const error = await api
.listTeams("my-org")
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(ApiPermissionError);
expect(error).toMatchObject({ status: 403, detail: "Team access denied" });
});

it("rejects repeated cursors and oversized limits without looping", async () => {
let requests = 0;
mswServer.use(
http.get("https://sentry.io/api/0/organizations/my-org/teams/", () => {
requests += 1;
return HttpResponse.json([teamFixture], {
headers: {
Link: '<https://sentry.io/api/0/organizations/my-org/teams/?cursor=again>; rel="next"; results="true"; cursor="again"',
},
});
}),
);

const api = new SentryApiService({ host: "sentry.io" });
await expect(api.listTeams("my-org", { limit: 5001 })).rejects.toThrow(
"limit must be an integer",
);
expect(requests).toBe(0);
await expect(api.listTeams("my-org", { limit: 3 })).rejects.toThrow(
"repeated pagination cursor",
);
expect(requests).toBe(2);
});
});

describe("host configuration", () => {
it("should handle hostname without protocol", () => {
const apiService = new SentryApiService({ host: "sentry.io" });
Expand Down
Loading
Loading