Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 2 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 30 additions & 1 deletion src/api/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,35 @@ export class BaseAPIClient {
return this.request<TData>(url.toString());
}

/**
* Fetch all pages of a paginated list endpoint.
* Follows `links.next` until no more pages are available.
*/
protected async listAll<TData>(
path: string,
params?: Record<string, string>,
): Promise<TData[]> {
const allData: TData[] = [];

let response = await this.get<TData>(path, params);
if (Array.isArray(response.data)) {
allData.push(...response.data);
} else {
return [response.data];
}

while (response.links?.next) {
// The next URL is a full absolute URL from Apple's API
// Use request() directly since the URL is already absolute
response = await this.request<TData>(response.links.next);
if (Array.isArray(response.data)) {
allData.push(...response.data);
}
}

return allData;
}

protected async patch<TData, TBody>(
path: string,
body: TBody,
Expand Down Expand Up @@ -107,4 +136,4 @@ export class BaseAPIClient {

return payload as APIResponse<TData, TIncluded>;
}
}
}
14 changes: 5 additions & 9 deletions src/api/resources/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,13 @@ export class BuildsClient extends BaseAPIClient {
}

/**
* List recent build runs for a workflow.
* List all build runs for a workflow, paginating through all results.
*/
async listForWorkflow(workflowId: string, limit?: number): Promise<CiBuildRun[]> {
const response = await this.get<CiBuildRun[]>(
async listForWorkflow(workflowId: string): Promise<CiBuildRun[]> {
return this.listAll<CiBuildRun>(
`/v1/ciWorkflows/${workflowId}/buildRuns`,
{
...(limit ? { limit: String(limit) } : {}),
},
{ limit: '200' },
);

return response.data;
}

/**
Expand All @@ -37,4 +33,4 @@ export class BuildsClient extends BaseAPIClient {

return response.data;
}
}
}
12 changes: 4 additions & 8 deletions src/api/resources/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ import type { CiProduct } from '../types.js';
*/
export class ProductsClient extends BaseAPIClient {
/**
* List Xcode Cloud products.
* List all Xcode Cloud products, paginating through all results.
*/
async list(limit?: number): Promise<CiProduct[]> {
const response = await this.get<CiProduct[]>('/v1/ciProducts', {
...(limit ? { limit: String(limit) } : {}),
});

return response.data;
async list(): Promise<CiProduct[]> {
return this.listAll<CiProduct>('/v1/ciProducts', { limit: '200' });
}
}
}
14 changes: 5 additions & 9 deletions src/api/resources/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,13 @@ type WorkflowAttributeUpdate = Partial<CiWorkflow['attributes']>;
*/
export class WorkflowsClient extends BaseAPIClient {
/**
* List workflows belonging to a product.
* List all workflows belonging to a product, paginating through all results.
*/
async listForProduct(productId: string, limit?: number): Promise<CiWorkflow[]> {
const response = await this.get<CiWorkflow[]>(
async listForProduct(productId: string): Promise<CiWorkflow[]> {
return this.listAll<CiWorkflow>(
`/v1/ciProducts/${productId}/workflows`,
{
...(limit ? { limit: String(limit) } : {}),
},
{ limit: '200' },
);

return response.data;
}

/**
Expand Down Expand Up @@ -122,4 +118,4 @@ export class WorkflowsClient extends BaseAPIClient {
): Promise<CiWorkflow> {
return this.updateById(workflowId, { actions });
}
}
}
8 changes: 2 additions & 6 deletions src/tools/build-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,23 @@ export function registerBuildRunTools(
'list_build_runs',
{
description:
'List recent build runs for a workflow, optionally filtered by outcome.',
'List recent build runs for a workflow, optionally filtered by outcome. Automatically paginates through all build runs.',
inputSchema: {
workflowId: z.string(),
limit: z.number().int().positive().max(200).optional(),
status: z.enum(['all', 'failed', 'pending', 'running', 'succeeded']).optional(),
},
},
async ({
workflowId,
limit,
status,
}: {
workflowId: string;
limit?: number;
status?: BuildRunStatusFilter;
}) => {
try {
const buildRuns = sortBuildRuns(
await client.builds.listForWorkflow(
parseIdentifier(workflowId, 'workflow'),
limit ?? 20,
),
Comment on lines 43 to 46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore a cap for build-run listings

For long-lived workflows, this now fetches every paginated build run and then returns every matching item from list_build_runs; before this path passed limit ?? 20, and the removed schema field leaves callers with no way to keep the MCP response bounded. A workflow with hundreds or thousands of historical runs can make routine list_build_runs calls slow or exceed response-size limits even when the caller only wants recent builds.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I guess this is debatable. @backslash-f Any take here?

);

Expand Down Expand Up @@ -99,4 +95,4 @@ function filterBuildRuns(
return buildRuns.filter(
(buildRun) => buildRun.attributes.completionStatus === 'SUCCEEDED',
);
}
}
18 changes: 7 additions & 11 deletions src/tools/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ export function registerDiscoveryTools(
'list_products',
{
description:
'List Xcode Cloud products available to the configured App Store Connect account.',
inputSchema: {
limit: z.number().int().positive().max(200).optional(),
},
'List Xcode Cloud products available to the configured App Store Connect account. Automatically paginates through all results.',
inputSchema: {},
},
async ({ limit }: { limit?: number }) => {
async () => {
try {
const products = await client.products.list(limit);
const products = await client.products.list();

return jsonResponse({
products: products.map((product) => ({
Expand All @@ -42,17 +40,15 @@ export function registerDiscoveryTools(
server.registerTool(
'list_workflows',
{
description: 'List workflows for a given Xcode Cloud product.',
description: 'List workflows for a given Xcode Cloud product. Automatically paginates through all results.',
inputSchema: {
productId: z.string(),
limit: z.number().int().positive().max(200).optional(),
},
},
async ({ productId, limit }: { productId: string; limit?: number }) => {
async ({ productId }: { productId: string }) => {
try {
const workflows = await client.workflows.listForProduct(
parseIdentifier(productId, 'product'),
limit,
);

return jsonResponse({
Expand Down Expand Up @@ -91,4 +87,4 @@ export function registerDiscoveryTools(
}
},
);
}
}
2 changes: 1 addition & 1 deletion src/utils/build-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function resolveBuildLocator(
}

const buildRuns = sortBuildRuns(
await client.builds.listForWorkflow(locator.workflowId!, 100),
await client.builds.listForWorkflow(locator.workflowId!),
);

if (locator.buildNumber !== undefined) {
Expand Down
2 changes: 1 addition & 1 deletion tests/build-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,4 @@ function createBuildRun(
},
},
};
}
}
123 changes: 123 additions & 0 deletions tests/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { BaseAPIClient } from '../src/api/base-client.js';
import type { APIResponse } from '../src/api/types.js';

/**
* Testable subclass that overrides request() to return mock data
* without making real HTTP calls. Since both get() and listAll()
* (for subsequent pages) call request(), this is the single point
* of interception needed.
*/
class TestableClient extends BaseAPIClient {
private responses: APIResponse<unknown[]>[] = [];
private callIndex = 0;

constructor(responses: APIResponse<unknown[]>[]) {
super(
{
keyId: 'test',
issuerId: 'test',
privateKey: '-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIOBR1JO5H6GH5RF2U8DT0SP9UAXKKO7H5JF7E6U3L2VJoAcGBSuBBAAi\nZW2CAQBEAPnJ5VqvF2i0QzY3N7Y4C7Q3L8KM0N9QF2L8GK5V0Y5H2X1D4W7R9T6M\n-----END EC PRIVATE KEY-----',
},
'https://api.appstoreconnect.apple.com',
);
this.responses = responses;
}

protected override async request<TData, TIncluded = never>(
_url: string,
_init?: RequestInit,
): Promise<APIResponse<TData, TIncluded>> {
const response = this.responses[this.callIndex] as unknown as APIResponse<TData, TIncluded>;
this.callIndex++;
return response;
}

public async testListAll(path: string, params?: Record<string, string>): Promise<unknown[]> {
return this.listAll(path, params);
}
}

test('listAll collects items from a single page with no next link', async () => {
const client = new TestableClient([
{
data: [{ id: '1' }, { id: '2' }],
links: { self: 'https://api.appstoreconnect.apple.com/v1/test' },
},
]);

const result = await client.testListAll('/v1/test');

assert.equal(result.length, 2);
assert.equal((result[0] as { id: string }).id, '1');
assert.equal((result[1] as { id: string }).id, '2');
});

test('listAll follows pagination links across multiple pages', async () => {
const client = new TestableClient([
{
data: [{ id: '1' }, { id: '2' }],
links: {
self: 'https://api.appstoreconnect.apple.com/v1/test?limit=200',
next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page2&limit=200',
},
},
{
data: [{ id: '3' }, { id: '4' }],
links: {
self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page2&limit=200',
next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page3&limit=200',
},
},
{
data: [{ id: '5' }],
links: { self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=page3&limit=200' },
},
]);

const result = await client.testListAll('/v1/test');

assert.equal(result.length, 5);
assert.deepEqual(
result.map((item) => (item as { id: string }).id),
['1', '2', '3', '4', '5'],
);
});

test('listAll returns empty array when first page has empty data', async () => {
const client = new TestableClient([
{
data: [],
links: { self: 'https://api.appstoreconnect.apple.com/v1/test' },
},
]);

const result = await client.testListAll('/v1/test');

assert.equal(result.length, 0);
});

test('listAll handles two pages correctly', async () => {
const client = new TestableClient([
{
data: [{ id: 'a' }],
links: {
self: 'https://api.appstoreconnect.apple.com/v1/test',
next: 'https://api.appstoreconnect.apple.com/v1/test?cursor=2',
},
},
{
data: [{ id: 'b' }],
links: { self: 'https://api.appstoreconnect.apple.com/v1/test?cursor=2' },
},
]);

const result = await client.testListAll('/v1/test');

assert.equal(result.length, 2);
assert.deepEqual(
result.map((item) => (item as { id: string }).id),
['a', 'b'],
);
});
Loading