Skip to content

Commit c47e607

Browse files
maxbeechbetegoncodex
authored
fix(tools): add cursor pagination to find_projects/find_organizations/find_teams (#1319)
`find_projects`, `find_organizations`, and `find_teams` can now fetch the remaining results through cursor pagination instead of requiring callers to narrow the query after the first 25 items. Requests return up to 25 items by default. The optional `limit` controls the page size (1–100), with no cap on the total number of items retrieved across pages. Continue with `nextCursor`, keeping the same filters, scope, and page size. `hasMore` reflects whether the API returned a cursor for another page. The implementation reuses the existing Link-header parser and response helpers, shares the cursor parameter, and updates generated definitions and the existing integration coverage. --------- Co-authored-by: betegon <miguelbetegongarcia@gmail.com> Co-authored-by: Codex (GPT-6) <noreply@openai.com>
1 parent 37df493 commit c47e607

14 files changed

Lines changed: 440 additions & 151 deletions

‎docs/operations/security.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,8 @@ catch (error) {
323323

324324
```typescript
325325
// Verify organization access
326-
const orgs = await apiService.listOrganizations();
327-
if (!orgs.find(org => org.slug === requestedOrg)) {
326+
const { organizations } = await apiService.listOrganizations();
327+
if (!organizations.find(org => org.slug === requestedOrg)) {
328328
throw new UserInputError("No access to organization");
329329
}
330330
```

‎packages/mcp-core/src/api-client/client.test.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,6 +1309,7 @@ describe("listOrganizations", () => {
13091309
if (url.includes("/organizations/")) {
13101310
return Promise.resolve({
13111311
ok: true,
1312+
headers: new Headers({ "content-type": "application/json" }),
13121313
json: () => Promise.resolve(mockOrgs),
13131314
});
13141315
}
@@ -1320,12 +1321,17 @@ describe("listOrganizations", () => {
13201321
accessToken: "test-token",
13211322
});
13221323

1323-
const result = await apiService.listOrganizations();
1324+
const { organizations, nextCursor } = await apiService.listOrganizations();
13241325

13251326
expect(callCount).toBe(1); // Single call, no region fanout
1326-
expect(result).toHaveLength(2);
1327-
expect(result).toContainEqual(expect.objectContaining({ slug: "org-us" }));
1328-
expect(result).toContainEqual(expect.objectContaining({ slug: "org-eu" }));
1327+
expect(organizations).toHaveLength(2);
1328+
expect(organizations).toContainEqual(
1329+
expect.objectContaining({ slug: "org-us" }),
1330+
);
1331+
expect(organizations).toContainEqual(
1332+
expect.objectContaining({ slug: "org-eu" }),
1333+
);
1334+
expect(nextCursor).toBeNull();
13291335
// Region fanout is no longer used
13301336
expect(globalThis.fetch).not.toHaveBeenCalledWith(
13311337
expect.stringContaining("/users/me/regions/"),
@@ -1345,6 +1351,7 @@ describe("listOrganizations", () => {
13451351
if (url.includes("/organizations/")) {
13461352
return Promise.resolve({
13471353
ok: true,
1354+
headers: new Headers({ "content-type": "application/json" }),
13481355
json: () => Promise.resolve(mockOrgs),
13491356
});
13501357
}
@@ -1356,11 +1363,12 @@ describe("listOrganizations", () => {
13561363
accessToken: "test-token",
13571364
});
13581365

1359-
const result = await apiService.listOrganizations();
1366+
const { organizations, nextCursor } = await apiService.listOrganizations();
13601367

13611368
expect(callCount).toBe(1); // Only 1 org call, no regions call
1362-
expect(result).toHaveLength(2);
1363-
expect(result).toEqual(mockOrgs);
1369+
expect(organizations).toHaveLength(2);
1370+
expect(organizations).toEqual(mockOrgs);
1371+
expect(nextCursor).toBeNull();
13641372
// Verify that regions endpoint was not called
13651373
expect(globalThis.fetch).not.toHaveBeenCalledWith(
13661374
expect.stringContaining("/users/me/regions/"),

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,19 +1614,20 @@ export class SentryApiService {
16141614
}
16151615

16161616
/**
1617-
* Lists all organizations accessible to the authenticated user.
1617+
* Lists a page of organizations accessible to the authenticated user.
16181618
*
16191619
* Queries the `/organizations/` endpoint on the root host.
16201620
*
16211621
* @param params Query parameters
16221622
* @param params.query Search query to filter organizations by name/slug
16231623
* @param params.limit Maximum number of organizations to return (defaults to 25)
1624-
* @returns Array of organizations across all accessible regions
1624+
* @param params.cursor Pagination cursor from a previous call's nextCursor
1625+
* @returns A page of organizations across all accessible regions, plus a cursor for the next page (null once exhausted)
16251626
*
16261627
* @example
16271628
* ```typescript
1628-
* const orgs = await apiService.listOrganizations();
1629-
* orgs.forEach(org => {
1629+
* const { organizations } = await apiService.listOrganizations();
1630+
* organizations.forEach(org => {
16301631
* // regionUrl present for Cloud Service, empty for self-hosted
16311632
* console.log(`${org.name} (${org.slug}) - ${org.links?.regionUrl || 'No region URL'}`);
16321633
* });
@@ -1635,7 +1636,8 @@ export class SentryApiService {
16351636
async listOrganizations(params?: {
16361637
query?: string;
16371638
limit?: number;
1638-
}): Promise<OrganizationList> {
1639+
cursor?: string | null;
1640+
}): Promise<{ organizations: OrganizationList; nextCursor: string | null }> {
16391641
const limit = params?.limit ?? 25;
16401642

16411643
// Build query parameters
@@ -1644,6 +1646,9 @@ export class SentryApiService {
16441646
if (params?.query) {
16451647
queryParams.set("query", params.query);
16461648
}
1649+
if (params?.cursor) {
1650+
queryParams.set("cursor", params.cursor);
1651+
}
16471652
const queryString = queryParams.toString();
16481653
const path = `/organizations/?${queryString}`;
16491654

@@ -1654,8 +1659,13 @@ export class SentryApiService {
16541659
host = "sentry.io";
16551660
}
16561661

1657-
const body = await this.requestJSON(path, undefined, { host });
1658-
return OrganizationListSchema.parse(body);
1662+
const response = await this.request(path, undefined, { host });
1663+
const body = await this.parseJsonResponse(response);
1664+
1665+
return {
1666+
organizations: OrganizationListSchema.parse(body),
1667+
nextCursor: getNextCursor(response.headers.get("link")),
1668+
};
16591669
}
16601670

16611671
/**
@@ -1675,31 +1685,40 @@ export class SentryApiService {
16751685
}
16761686

16771687
/**
1678-
* Lists teams within an organization.
1688+
* Lists a page of teams within an organization.
16791689
*
16801690
* @param organizationSlug Organization identifier
16811691
* @param params Query parameters
16821692
* @param params.query Search query to filter teams by name/slug
16831693
* @param params.limit Maximum number of teams to return
1694+
* @param params.cursor Pagination cursor from a previous call's nextCursor
16841695
* @param opts Request options including host override
1685-
* @returns Array of teams in the organization
1696+
* @returns A page of teams in the organization, plus a cursor for the next page (null once exhausted)
16861697
*/
16871698
async listTeams(
16881699
organizationSlug: string,
1689-
params?: { query?: string; limit?: number },
1700+
params?: { query?: string; limit?: number; cursor?: string | null },
16901701
opts?: RequestOptions,
1691-
): Promise<TeamList> {
1702+
): Promise<{ teams: TeamList; nextCursor: string | null }> {
16921703
const queryParams = new URLSearchParams();
16931704
queryParams.set("per_page", String(params?.limit ?? 25));
16941705
if (params?.query) {
16951706
queryParams.set("query", params.query);
16961707
}
1708+
if (params?.cursor) {
1709+
queryParams.set("cursor", params.cursor);
1710+
}
16971711
const queryString = queryParams.toString();
16981712
const teamsPath = apiPath`/organizations/${organizationSlug}/teams/`;
16991713
const path = `${teamsPath}?${queryString}`;
17001714

1701-
const body = await this.requestJSON(path, undefined, opts);
1702-
return TeamListSchema.parse(body);
1715+
const response = await this.request(path, undefined, opts);
1716+
const body = await this.parseJsonResponse(response);
1717+
1718+
return {
1719+
teams: TeamListSchema.parse(body),
1720+
nextCursor: getNextCursor(response.headers.get("link")),
1721+
};
17031722
}
17041723

17051724
/**
@@ -1740,25 +1759,34 @@ export class SentryApiService {
17401759
* @param params Query parameters
17411760
* @param params.query Search query to filter projects by name/slug
17421761
* @param params.limit Maximum number of projects to return
1762+
* @param params.cursor Pagination cursor from a previous call's nextCursor
17431763
* @param opts Request options
1744-
* @returns Array of projects in the organization
1764+
* @returns Projects in the organization, plus a cursor for the next page (null once exhausted)
17451765
*/
17461766
async listProjects(
17471767
organizationSlug: string,
1748-
params?: { query?: string; limit?: number },
1768+
params?: { query?: string; limit?: number; cursor?: string | null },
17491769
opts?: RequestOptions,
1750-
): Promise<ProjectList> {
1770+
): Promise<{ projects: ProjectList; nextCursor: string | null }> {
17511771
const queryParams = new URLSearchParams();
17521772
queryParams.set("per_page", String(params?.limit ?? 25));
17531773
if (params?.query) {
17541774
queryParams.set("query", params.query);
17551775
}
1776+
if (params?.cursor) {
1777+
queryParams.set("cursor", params.cursor);
1778+
}
17561779
const queryString = queryParams.toString();
17571780
const projectsPath = apiPath`/organizations/${organizationSlug}/projects/`;
17581781
const path = `${projectsPath}?${queryString}`;
17591782

1760-
const body = await this.requestJSON(path, undefined, opts);
1761-
return ProjectListSchema.parse(body);
1783+
const response = await this.request(path, undefined, opts);
1784+
const body = await this.parseJsonResponse(response);
1785+
1786+
return {
1787+
projects: ProjectListSchema.parse(body),
1788+
nextCursor: getNextCursor(response.headers.get("link")),
1789+
};
17621790
}
17631791

17641792
async listDashboards(

‎packages/mcp-core/src/schema.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export const ParamSearchQuery = z
5050
"Search query to filter results by name or slug. Use this to narrow down results when there are many items.",
5151
);
5252

53+
export const ParamCursor = z
54+
.string()
55+
.describe(
56+
"Pagination cursor from a previous call's nextCursor. Reuse it with the same filters, scope, and limit to fetch the next page.",
57+
);
58+
5359
export const ParamIssueShortId = z
5460
.string()
5561
.toUpperCase()

‎packages/mcp-core/src/server.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,7 @@ describe("buildServer", () => {
14141414
},
14151415
],
14161416
hasMore: false,
1417+
nextCursor: null,
14171418
});
14181419
expect(getTextContent(result)).toBe(
14191420
getGeneratedTextFromStructuredContent(result),

0 commit comments

Comments
 (0)