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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

# xcode-cloud-mcp

Minimal MCP server for discovering Xcode Cloud products, inspecting and editing workflows, monitoring build runs, and retrieving build issues, logs, test summaries, and UI test artifacts through the App Store Connect API.
MCP server for discovering Xcode Cloud products, inspecting and editing workflows, starting and monitoring build runs, and retrieving build issues, logs, test summaries, and UI test artifacts through the App Store Connect API.

## Features

Expand All @@ -19,6 +19,7 @@ Minimal MCP server for discovering Xcode Cloud products, inspecting and editing
| Discover workflows | `list_workflows` | "List the workflows for product `def456`." | `Feature Branch`, `description`, `isEnabled: true`, `containerFilePath: Chauffeur.xcodeproj` |
| Inspect workflow configuration | `get_workflow_details` | "Show me the full workflow details for `abc123`, including environment and actions." | `general`, `environment`, `startConditions`, `actions`, `postActions` |
| Monitor running or recent builds | `list_build_runs` | "Show me the running builds for workflow `abc123` so I can monitor them." | `number: 93`, `executionProgress: RUNNING`, `completionStatus: null`, `startedDate: ...` |
| Start an Xcode Cloud build | `start_build` | "Start one build for workflow `abc123`." | newly created build-run ID, number, and execution state |
| Enable or disable a workflow | `set_workflow_enabled` | "Disable workflow `abc123` while we are testing new settings." | `operation.type: set_workflow_enabled`, `workflow.general.isEnabled: false` |
| Update name, description, or clean mode | `update_workflow_general` | "Rename workflow `abc123` to `Feature Branch v2` and adjust its description." | `changedFields: [name, description]`, updated `workflow.general` |
| Update start conditions explicitly | `update_workflow_start_conditions` | "Change workflow `abc123` so pull-request builds no longer auto-cancel." | updated `workflow.startConditions.pullRequest.autoCancel: false` |
Expand All @@ -37,6 +38,14 @@ Build lookup is workflow-scoped. Retrieval tools accept a direct `buildRunId`, o

`list_build_runs` supports `status: "all" | "failed" | "succeeded" | "running" | "pending"` and an optional `limit`, which defaults to `20`, so agents can poll active workflows without post-processing every run locally or inflating MCP response size.

## Build Start Behavior

`start_build` starts exactly one build run through Apple's public `POST /v1/ciBuildRuns` endpoint. `workflowId` is required. The optional `sourceBranchOrTagId` and `pullRequestId` values are App Store Connect resource IDs, not branch names, tag names, or pull-request numbers; `buildRunId` may be a bare resource ID or an `xcode-cloud://build-run/...` URI. The optional `clean` value applies only to the new run and does not modify the workflow.

The operation is externally side-effecting and non-idempotent: repeating the same invocation can create another build. The server sends one POST and does not automatically retry an ambiguous failure; inspect `list_build_runs` before deciding whether to try again.

Apple's public App Store Connect API does not expose an operation for canceling an individual Xcode Cloud build run. This server therefore provides no cancellation tool and does not disable or otherwise mutate a workflow as a cancellation surrogate.

## Requirements

- Node.js `20+`
Expand Down Expand Up @@ -84,6 +93,7 @@ codex mcp add xcode-cloud \
- `list_workflows(productId)`
- `get_workflow_details(workflowId)`
- `list_build_runs(workflowId, limit?, status?)`
- `start_build(workflowId, clean?, sourceBranchOrTagId?, pullRequestId?, buildRunId?)`
- `set_workflow_enabled(workflowId, enabled)`
- `update_workflow_general(workflowId, name?, description?, clean?)`
- `update_workflow_start_conditions(workflowId, branchStartCondition?, manualBranchStartCondition?, pullRequestStartCondition?, manualPullRequestStartCondition?, scheduledStartCondition?, tagStartCondition?, manualTagStartCondition?)`
Expand Down Expand Up @@ -233,8 +243,8 @@ The audience is required and cannot be null in this preset:

The general `update_workflow_actions` tool accepts only these two audience values or null/omission (Deployment Preparation = None). Use the preset when requesting a TestFlight release candidate: null/omission is rejected before any API mutation. Workflow details retain the raw `buildDistributionAudience` and add the human-readable `deploymentPreparation` value for each action.

Creating an archive, making it eligible for TestFlight, and assigning its processed build to tester groups are separate steps. This preset configures eligibility; it does not start a build, guarantee successful upload/processing, or assign testers. To distribute automatically, edit the workflow in Xcode or App Store Connect, add a TestFlight Internal Testing post-action, and select the internal group.
Creating an archive, making it eligible for TestFlight, and assigning its processed build to tester groups are separate steps. This preset configures eligibility; it does not itself start a build, guarantee successful upload/processing, or assign testers. Call `start_build` explicitly to create a build run. To distribute automatically, edit the workflow in Xcode or App Store Connect, add a TestFlight Internal Testing post-action, and select the internal group.

Apple's [OpenAPI specification](https://developer.apple.com/sample-code/app-store-connect/app-store-connect-openapi-specification.zip), version 4.4.1, inspected on 2026-09-07, exposes no post-action field or relationship in `CiWorkflow`, `CiWorkflowCreateRequest`, or `CiWorkflowUpdateRequest`, and no workflow post-action endpoint. Consequently, workflow responses explicitly report `testFlightDistribution.status: "UNSUPPORTED_BY_APPLE_API"` and group assignment as `UNKNOWN`; the legacy empty `postActions` array is not evidence that no post-actions exist. See Apple's [BuildAudienceType](https://developer.apple.com/documentation/appstoreconnectapi/buildaudiencetype) and [TestFlight distribution guide](https://developer.apple.com/documentation/xcode/distributing-your-xcode-cloud-builds-through-testflight).

A separate build-start/wait/processing/beta-group orchestration could use the TestFlight API, but is outside this server's current scope and would not be a native Xcode Cloud post-action.
Waiting for completion, TestFlight processing, beta-group assignment, and broader release orchestration remain outside this server's current scope and would not be native Xcode Cloud post-actions.
4 changes: 2 additions & 2 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@thatfactory/xcode-cloud-mcp",
"version": "0.6.2",
"description": "Minimal MCP server for discovering Xcode Cloud workflows and retrieving build logs, issues, and test artifacts.",
"version": "0.7.0",
"description": "MCP server for configuring Xcode Cloud workflows, starting builds, and retrieving build logs, issues, and test artifacts.",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
Expand Down
20 changes: 20 additions & 0 deletions src/api/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ export class BaseAPIClient {
});
}

protected async post<TData, TBody>(
path: string,
body: TBody,
): Promise<APIResponse<TData>>;
protected async post<TData, TBody, TIncluded>(
path: string,
body: TBody,
): Promise<APIResponse<TData, TIncluded>>;
protected async post<TData, TBody, TIncluded>(
path: string,
body: TBody,
): Promise<APIResponse<TData, TIncluded>> {
const url = new URL(path, this.baseUrl);

return this.request<TData, TIncluded>(url.toString(), {
method: 'POST',
body: JSON.stringify(body),
});
}

protected async download(url: string): Promise<Uint8Array> {
const response = await fetch(url, {
headers: {
Expand Down
67 changes: 66 additions & 1 deletion src/api/resources/builds.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,77 @@
import { BaseAPIClient } from '../base-client.js';
import type { CiBuildAction, CiBuildRun } from '../types.js';
import type {
CiBuildAction,
CiBuildRun,
CiBuildRunCreateRequest,
CiBuildRunStartOptions,
} from '../types.js';

/**
* Build run endpoints.
*/
export class BuildsClient extends BaseAPIClient {
static readonly buildLocatorScanLimit = 2000;

/**
* Start exactly one Xcode Cloud build run.
*/
async start(options: CiBuildRunStartOptions): Promise<CiBuildRun> {
const attributes: CiBuildRunCreateRequest['data']['attributes'] = {};
if (options.clean !== undefined) {
attributes.clean = options.clean;
}

const relationships: CiBuildRunCreateRequest['data']['relationships'] = {
workflow: {
data: {
type: 'ciWorkflows',
id: options.workflowId,
},
},
};

if (options.sourceBranchOrTagId !== undefined) {
relationships.sourceBranchOrTag = {
data: {
type: 'scmGitReferences',
id: options.sourceBranchOrTagId,
},
};
}

if (options.pullRequestId !== undefined) {
relationships.pullRequest = {
data: {
type: 'scmPullRequests',
id: options.pullRequestId,
},
};
}

if (options.buildRunId !== undefined) {
relationships.buildRun = {
data: {
type: 'ciBuildRuns',
id: options.buildRunId,
},
};
}

const request: CiBuildRunCreateRequest = {
data: {
type: 'ciBuildRuns',
attributes,
relationships,
},
};
const response = await this.post<CiBuildRun, CiBuildRunCreateRequest>(
'/v1/ciBuildRuns',
request,
);

return response.data;
}

/**
* Get a build run by id.
*/
Expand Down
42 changes: 42 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,48 @@ export interface CiBuildRun {
};
}

interface ResourceIdentifier<TType extends string> {
type: TType;
id: string;
}

/**
* Options for creating one Xcode Cloud build run.
*/
export interface CiBuildRunStartOptions {
workflowId: string;
clean?: boolean;
sourceBranchOrTagId?: string;
pullRequestId?: string;
buildRunId?: string;
}

/**
* App Store Connect request envelope for starting one Xcode Cloud build run.
*/
export interface CiBuildRunCreateRequest {
data: {
type: 'ciBuildRuns';
attributes: {
clean?: boolean;
};
relationships: {
workflow: {
data: ResourceIdentifier<'ciWorkflows'>;
};
sourceBranchOrTag?: {
data: ResourceIdentifier<'scmGitReferences'>;
};
pullRequest?: {
data: ResourceIdentifier<'scmPullRequests'>;
};
buildRun?: {
data: ResourceIdentifier<'ciBuildRuns'>;
};
};
};
}

/**
* Xcode Cloud build action.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function createServer(): McpServer {

const server = new McpServer({
name: 'Xcode Cloud MCP',
version: '0.6.2',
version: '0.7.0',
});

registerDiscoveryTools(server, client);
Expand Down
90 changes: 89 additions & 1 deletion src/tools/build-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,101 @@ import { errorResponse, jsonResponse } from '../utils/tool-response.js';

type BuildRunStatusFilter = 'all' | 'failed' | 'pending' | 'running' | 'succeeded';

const identifierSchema = z.string().trim().min(1);

/**
* Register build run listing tools.
*/
export function registerBuildRunTools(
server: McpServer,
client: AppStoreConnectClient,
): void {
server.registerTool(
'start_build',
{
title: 'Start Xcode Cloud Build',
description:
"Start exactly one new Xcode Cloud build through Apple's public App Store Connect API. Each successful invocation creates a new build run, and repeating the same call can create another build. Individual build-run cancellation is not supported by Apple's public API.",
inputSchema: {
workflowId: identifierSchema,
clean: z
.boolean()
.optional()
.describe('Override clean-build behavior for this run only.'),
sourceBranchOrTagId: identifierSchema
.optional()
.describe(
'App Store Connect scmGitReferences resource ID, not a branch or tag name.',
),
pullRequestId: identifierSchema
.optional()
.describe(
'App Store Connect scmPullRequests resource ID, not a pull-request number.',
),
buildRunId: identifierSchema
.optional()
.describe(
'Optional prior build-run ID or xcode-cloud://build-run URI.',
),
},
annotations: {
readOnlyHint: false,
idempotentHint: false,
openWorldHint: true,
destructiveHint: false,
},
},
async ({
workflowId,
clean,
sourceBranchOrTagId,
pullRequestId,
buildRunId,
}: {
workflowId: string;
clean?: boolean;
sourceBranchOrTagId?: string;
pullRequestId?: string;
buildRunId?: string;
}) => {
try {
const parsedWorkflowId = parseIdentifier(workflowId, 'workflow');
const buildRun = await client.builds.start({
workflowId: parsedWorkflowId,
clean,
sourceBranchOrTagId,
pullRequestId,
buildRunId:
buildRunId === undefined
? undefined
: parseIdentifier(buildRunId, 'build-run'),
});

return jsonResponse({
operation: {
type: 'start_build',
applied: true,
},
buildRun: {
id: buildRun.id,
workflowId:
buildRun.relationships?.workflow?.data.id ?? parsedWorkflowId,
number: buildRun.attributes.number,
executionProgress: buildRun.attributes.executionProgress,
completionStatus: buildRun.attributes.completionStatus,
createdDate: buildRun.attributes.createdDate,
startedDate: buildRun.attributes.startedDate,
finishedDate: buildRun.attributes.finishedDate,
isPullRequestBuild: buildRun.attributes.isPullRequestBuild,
sourceCommit: buildRun.attributes.sourceCommit,
},
});
} catch (error) {
return errorResponse(error);
}
},
);

server.registerTool(
'list_build_runs',
{
Expand Down Expand Up @@ -99,4 +187,4 @@ function filterBuildRuns(
return buildRuns.filter(
(buildRun) => buildRun.attributes.completionStatus === 'SUCCEEDED',
);
}
}
Loading