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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ codex mcp add xcode-cloud \
- `update_workflow_general(workflowId, name?, description?, clean?)`
- `update_workflow_start_conditions(workflowId, branchStartCondition?, manualBranchStartCondition?, pullRequestStartCondition?, manualPullRequestStartCondition?, scheduledStartCondition?, tagStartCondition?, manualTagStartCondition?)`
- `update_workflow_actions(workflowId, actions)`
- `configure_manual_release_candidate(workflowId, scheme, branch, buildDistributionAudience)`
- `get_build_issues(buildRunId? workflowId? buildNumber? buildSelector?)`
- `get_build_logs(buildRunId? workflowId? buildNumber? buildSelector?, maxCharacters?)`
- `materialize_build_logs(buildRunId? workflowId? buildNumber? buildSelector?)`
Expand Down Expand Up @@ -174,7 +175,7 @@ Notes:

- `environment` includes repository, `xcodeVersion`, and `macOsVersion` when App Store Connect returns them.
- `actions` includes action type, scheme, platform, destination, required-to-pass state, and test-plan details when present.
- `postActions` is currently returned as an empty array with a note because the App Store Connect workflow payload does not expose separate post-actions in the observed API response.
- `postActions` is currently returned as an empty array with a note because Apple does not expose workflow post-actions in its public API. This does not mean no post-actions are configured; `testFlightDistribution` reports `UNSUPPORTED_BY_APPLE_API` and an actionable next step.

## Workflow Update Behavior

Expand Down Expand Up @@ -210,3 +211,30 @@ Build the package:
```bash
npm run build
```


### Manual TestFlight release candidates

`configure_manual_release_candidate` converts an existing workflow to one macOS archive action (`ARCHIVE`, `MACOS`, `ANY_MAC`) with manual starts from an exact branch. It replaces the entire action list and all seven start conditions in one PATCH, clearing automatic branch, pull-request, tag, and scheduled starts plus manual tag and pull-request starts. Fetch `get_workflow_details` first if you need to retain the original configuration. The preset preserves the workflow's enabled state, name, environment, and clean-build setting; enable a disabled workflow separately when ready.

```json
{
"workflowId": "abc123",
"scheme": "Headroom",
"branch": "main",
"buildDistributionAudience": "APP_STORE_ELIGIBLE"
}
```

The audience is required and cannot be null in this preset:

- `APP_STORE_ELIGIBLE`: Deployment Preparation = TestFlight and App Store.
- `INTERNAL_ONLY`: Deployment Preparation = TestFlight internal testing only.

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.

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.
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thatfactory/xcode-cloud-mcp",
"version": "0.5.3",
"version": "0.6.0",
"description": "Minimal MCP server for discovering Xcode Cloud workflows and retrieving build logs, issues, and test artifacts.",
"license": "MIT",
"type": "module",
Expand Down
4 changes: 3 additions & 1 deletion src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ export interface CiWorkflow {
/**
* Xcode Cloud workflow action.
*/
export type BuildAudienceType = 'INTERNAL_ONLY' | 'APP_STORE_ELIGIBLE';

export interface CiWorkflowAction {
actionType: string;
buildDistributionAudience?: string | null;
buildDistributionAudience?: BuildAudienceType | null;
destination?: string | null;
isRequiredToPass?: boolean | null;
name: string;
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.5.3',
version: '0.6.0',
});

registerDiscoveryTools(server, client);
Expand Down
59 changes: 57 additions & 2 deletions src/tools/workflow-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import { formatWorkflowDetailsResponse } from '../utils/workflow-details.js';

const conditionSchema = z.record(z.string(), z.unknown()).nullable();

const audienceSchema = z.enum(['INTERNAL_ONLY', 'APP_STORE_ELIGIBLE']);

const releaseCandidateSchema = z.object({
workflowId: z.string(),
scheme: z.string().trim().min(1),
branch: z.string().trim().min(1),
buildDistributionAudience: audienceSchema,
}).strict();

const actionSchema = z.object({
name: z.string(),
actionType: z.string(),
destination: z.string().nullable().optional(),
buildDistributionAudience: z.string().nullable().optional(),
buildDistributionAudience: audienceSchema.nullable().optional(),
testConfiguration: z
.object({
kind: z.string().nullable().optional(),
Expand All @@ -36,6 +45,52 @@ export function registerWorkflowUpdateTools(
server: McpServer,
client: AppStoreConnectClient,
): void {
server.registerTool(
'configure_manual_release_candidate',
{
description:
'Replace all actions with one macOS archive and all start conditions with manual builds from one exact branch. Requires TestFlight Deployment Preparation. Preserves enabled state. Does not configure tester-group post-actions; use Xcode or App Store Connect for those.',
inputSchema: releaseCandidateSchema.shape,
},
async (arguments_) => {
try {
const { workflowId, scheme, branch, buildDistributionAudience } =
releaseCandidateSchema.parse(arguments_);
const workflowIdentifier = parseIdentifier(workflowId, 'workflow');
await client.workflows.updateById(workflowIdentifier, {
branchStartCondition: null,
pullRequestStartCondition: null,
scheduledStartCondition: null,
tagStartCondition: null,
manualPullRequestStartCondition: null,
manualTagStartCondition: null,
manualBranchStartCondition: {
source: {
isAllMatch: false,
patterns: [{ pattern: branch, isPrefix: false }],
},
},
actions: [{
name: 'Release Candidate',
actionType: 'ARCHIVE',
platform: 'MACOS',
destination: 'ANY_MAC',
scheme,
buildDistributionAudience,
isRequiredToPass: true,
}],
});
const updated = await client.workflows.getById(workflowIdentifier);
return jsonResponse({
operation: { applied: true, type: 'configure_manual_release_candidate' },
...formatWorkflowDetailsResponse(updated.workflow, updated.included),
});
} catch (error) {
return errorResponse(error);
}
},
);

server.registerTool(
'set_workflow_enabled',
{
Expand Down Expand Up @@ -247,7 +302,7 @@ export function registerWorkflowUpdateTools(
const workflowIdentifier = parseIdentifier(workflowId, 'workflow');
await client.workflows.updateActions(
workflowIdentifier,
actions.map(normalizeAction),
z.array(actionSchema).parse(actions).map(normalizeAction),
);
const updated = await client.workflows.getById(workflowIdentifier);

Expand Down
13 changes: 12 additions & 1 deletion src/utils/workflow-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,13 @@ export function formatWorkflowDetailsResponse(
},
actions: actions.map(formatWorkflowAction),
postActions: [],
testFlightDistribution: {
status: 'UNSUPPORTED_BY_APPLE_API',
automaticTesterGroupAssignment: 'UNKNOWN',
nextAction: 'In Xcode or App Store Connect, edit the workflow and add a TestFlight Internal Testing post-action, then select the internal tester group. Archive eligibility alone does not assign builds to testers.',
},
postActionsNote:
'The App Store Connect workflow payload did not expose separate post-actions, so this field is empty unless Apple adds that data.',
'Post-actions are not exposed by Apple API; the empty list does not mean none are configured. Configure a TestFlight internal-group post-action in Xcode or App Store Connect.',
},
};
}
Expand Down Expand Up @@ -113,6 +118,12 @@ function formatWorkflowAction(action: CiWorkflowAction) {
scheme: action.scheme ?? null,
destination: action.destination ?? null,
buildDistributionAudience: action.buildDistributionAudience ?? null,
deploymentPreparation:
action.buildDistributionAudience === 'INTERNAL_ONLY'
? 'TestFlight internal testing only'
: action.buildDistributionAudience === 'APP_STORE_ELIGIBLE'
? 'TestFlight and App Store'
: action.buildDistributionAudience == null ? 'None' : 'Unknown',
isRequiredToPass: action.isRequiredToPass ?? null,
testConfiguration: action.testConfiguration
? {
Expand Down
12 changes: 12 additions & 0 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ test('server starts over stdio and exposes the expected tools', async () => {

assert.deepEqual(toolNames, [
'cleanup_saved_logs',
'configure_manual_release_candidate',
'get_build_issues',
'get_build_logs',
'get_failed_tests',
Expand All @@ -49,5 +50,16 @@ test('server starts over stdio and exposes the expected tools', async () => {
'update_workflow_start_conditions',
]);

const invalidPreset = await client.callTool({
name: 'configure_manual_release_candidate',
arguments: { workflowId: 'workflow-1', scheme: 'App', branch: 'main', buildDistributionAudience: null },
});
assert.equal(invalidPreset.isError, true);
const invalidAction = await client.callTool({
name: 'update_workflow_actions',
arguments: { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: 'TYPO' }] },
});
assert.equal(invalidAction.isError, true);

await client.close();
});
98 changes: 98 additions & 0 deletions tests/workflow-release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { AppStoreConnectClient } from '../src/api/client.js';
import type { CiWorkflow } from '../src/api/types.js';
import { registerWorkflowUpdateTools } from '../src/tools/workflow-updates.js';
import { formatWorkflowDetailsResponse } from '../src/utils/workflow-details.js';

function fixture() {
const workflow: CiWorkflow = {
id: 'workflow-1', type: 'ciWorkflows',
attributes: { name: 'Existing', isEnabled: false, clean: false,
containerFilePath: 'App.xcodeproj', lastModifiedDate: null,
branchStartCondition: { source: { isAllMatch: true } },
pullRequestStartCondition: {}, scheduledStartCondition: {}, tagStartCondition: {},
manualPullRequestStartCondition: {}, manualTagStartCondition: {},
actions: [{ name: 'Tests', actionType: 'TEST' }],
},
};
const writes: any[] = [];
const registry = new Map<string, { schema: z.ZodObject; handler: (args: any) => Promise<any> }>();
const server = { registerTool(name: string, config: any, handler: any) {
registry.set(name, { schema: z.object(config.inputSchema), handler });
}};
const client = { workflows: {
async updateById(id: string, attributes: any) {
writes.push({ id, attributes }); Object.assign(workflow.attributes, attributes); return workflow;
},
async updateActions(id: string, actions: any) { writes.push({ id, actions }); },
async getById() { return { workflow, included: [] }; },
}};
registerWorkflowUpdateTools(server as unknown as McpServer, client as unknown as AppStoreConnectClient);
return { workflow, writes, registry };
}

for (const audience of ['APP_STORE_ELIGIBLE', 'INTERNAL_ONLY']) {
test(`manual archive preset uses ${audience} and clears every other trigger in one update`, async () => {
const { registry, workflow, writes } = fixture();
const tool = registry.get('configure_manual_release_candidate')!;
const result = await tool.handler(tool.schema.parse({ workflowId: workflow.id,
scheme: 'Headroom', branch: 'main', buildDistributionAudience: audience }));
assert.notEqual(result.isError, true);
assert.equal(writes.length, 1);
assert.equal(writes[0].id, workflow.id);
const a = writes[0].attributes;
for (const key of ['branchStartCondition', 'pullRequestStartCondition', 'scheduledStartCondition',
'tagStartCondition', 'manualPullRequestStartCondition', 'manualTagStartCondition']) assert.equal(a[key], null);
assert.deepEqual(a.manualBranchStartCondition, { source: { isAllMatch: false,
patterns: [{ pattern: 'main', isPrefix: false }] } });
assert.deepEqual(a.actions, [{ name: 'Release Candidate', actionType: 'ARCHIVE',
platform: 'MACOS', destination: 'ANY_MAC', scheme: 'Headroom',
buildDistributionAudience: audience, isRequiredToPass: true }]);
assert.equal(workflow.attributes.isEnabled, false);
assert.equal(workflow.attributes.name, 'Existing');
const payload = JSON.parse(result.content[0].text);
assert.equal(payload.workflow.actions[0].deploymentPreparation, audience === 'INTERNAL_ONLY'
? 'TestFlight internal testing only' : 'TestFlight and App Store');
assert.equal(payload.workflow.testFlightDistribution.status, 'UNSUPPORTED_BY_APPLE_API');
assert.equal(payload.workflow.testFlightDistribution.automaticTesterGroupAssignment, 'UNKNOWN');
});
}

test('release candidate rejects missing, null, invalid audience and blank scheme/branch before writes', async () => {
const { registry, writes } = fixture();
const tool = registry.get('configure_manual_release_candidate')!;
const valid = { workflowId: 'workflow-1', scheme: 'App', branch: 'main', buildDistributionAudience: 'INTERNAL_ONLY' };
for (const change of [{ buildDistributionAudience: null }, { buildDistributionAudience: undefined },
{ buildDistributionAudience: 'TESTFLIGHT' }, { scheme: ' ' }, { branch: '' }]) {
const args = { ...valid, ...change };
assert.equal(tool.schema.safeParse(args).success, false);
assert.equal((await tool.handler(args)).isError, true);
}
assert.equal(writes.length, 0);
});

test('general action edits accept nullable enum but reject arbitrary audiences', async () => {
const { registry, writes } = fixture();
const tool = registry.get('update_workflow_actions')!;
for (const audience of [null, undefined, 'INTERNAL_ONLY', 'APP_STORE_ELIGIBLE']) {
const args = { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: audience }] };
assert.equal(tool.schema.safeParse(args).success, true);
assert.notEqual((await tool.handler(args)).isError, true);
}
const invalid = { workflowId: 'workflow-1', actions: [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: 'TYPO' }] };
assert.equal(tool.schema.safeParse(invalid).success, false);
assert.equal((await tool.handler(invalid)).isError, true);
assert.equal(writes.length, 4);
});

test('read-back reports None for omitted/null preparation and Unknown for future values', () => {
const { workflow } = fixture();
for (const audience of [null, undefined, 'FUTURE']) {
workflow.attributes.actions = [{ name: 'Archive', actionType: 'ARCHIVE', buildDistributionAudience: audience as any }];
const result = formatWorkflowDetailsResponse(workflow, []);
assert.equal(result.workflow.actions[0].deploymentPreparation, audience === 'FUTURE' ? 'Unknown' : 'None');
}
});