Skip to content

Commit 102201e

Browse files
committed
Add workflow update tools
1 parent a7ba073 commit 102201e

9 files changed

Lines changed: 676 additions & 126 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ Minimal MCP server for discovering Xcode Cloud products and workflows, then retr
1818
| Discover workflows | `list_workflows` | "List the workflows for product `def456`." | `Feature Branch`, `description`, `isEnabled: true`, `containerFilePath: Chauffeur.xcodeproj` |
1919
| Inspect workflow configuration | `get_workflow_details` | "Show me the full workflow details for `abc123`, including environment and actions." | `general`, `environment`, `startConditions`, `actions`, `postActions` |
2020
| 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: ...` |
21+
| 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` |
22+
| 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` |
23+
| 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` |
24+
| Replace the workflow action list | `update_workflow_actions` | "Remove the archive action from workflow `abc123`, then add it back once the experiment is done." | `actionCount: 4` after removal, then `actionCount: 5` after restore |
2125
| See build health quickly | `get_build_issues` | "What went wrong in the latest failing build for workflow `abc123`?" | `issueCounts: { errors: 1, testFailures: 3, warnings: 2 }` |
2226
| Read compact build log summaries | `get_build_logs` | "Retrieve logs of build `81` and summarize the failure." | `failedTests`, `highlights`, `excerpt`, `savedLogsDirectory` |
2327
| Materialize logs for local grep | `materialize_build_logs` | "Download the logs for build `81` so I can grep them locally." | `savedLogsDirectory: /var/folders/...`, `savedLogs: [...]` |
@@ -77,6 +81,10 @@ codex mcp add xcode-cloud \
7781
- `list_workflows(productId, limit?)`
7882
- `get_workflow_details(workflowId)`
7983
- `list_build_runs(workflowId, limit?, status?)`
84+
- `set_workflow_enabled(workflowId, enabled)`
85+
- `update_workflow_general(workflowId, name?, description?, clean?)`
86+
- `update_workflow_start_conditions(workflowId, branchStartCondition?, manualBranchStartCondition?, pullRequestStartCondition?, manualPullRequestStartCondition?, scheduledStartCondition?, tagStartCondition?, manualTagStartCondition?)`
87+
- `update_workflow_actions(workflowId, actions)`
8088
- `get_build_issues(buildRunId? workflowId? buildNumber? buildSelector?)`
8189
- `get_build_logs(buildRunId? workflowId? buildNumber? buildSelector?, maxCharacters?)`
8290
- `materialize_build_logs(buildRunId? workflowId? buildNumber? buildSelector?)`
@@ -145,6 +153,10 @@ List the workflows for product def456 and then summarize the latest build.
145153
Show me the full workflow details for workflow abc123, including environment, start conditions, actions, and whether it is enabled.
146154
```
147155

156+
```text
157+
Disable workflow abc123, remove the archive action, then restore the original action list after the experiment.
158+
```
159+
148160
## Workflow Details Behavior
149161

150162
`get_workflow_details` returns the live workflow configuration exposed by App Store Connect, grouped into:
@@ -161,6 +173,21 @@ Notes:
161173
- `actions` includes action type, scheme, platform, destination, required-to-pass state, and test-plan details when present.
162174
- `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.
163175

176+
## Workflow Update Behavior
177+
178+
The workflow update tools are intentionally explicit:
179+
180+
- `set_workflow_enabled` only toggles `isEnabled`
181+
- `update_workflow_general` only changes `name`, `description`, and `clean`
182+
- `update_workflow_start_conditions` only changes the start-condition objects you pass
183+
- `update_workflow_actions` replaces the full `actions` array, so callers should fetch the current workflow first and then send the final desired action list
184+
185+
Important restriction:
186+
187+
- if the workflow has `Restrict Editing` enabled in Xcode Cloud, edits can fail even when the App Store Connect API key has `App Manager` access
188+
- for MCP edits to work reliably, disable the `Restrict Editing` checkbox for that workflow before using the write tools
189+
- if Apple still rejects the request after that, use a stronger API key role such as `Admin`
190+
164191
## Local Development
165192

166193
Install dependencies:

src/api/base-client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ export class BaseAPIClient {
3636
return this.request<TData>(url.toString());
3737
}
3838

39+
protected async patch<TData, TBody>(
40+
path: string,
41+
body: TBody,
42+
): Promise<APIResponse<TData>>;
43+
protected async patch<TData, TBody, TIncluded>(
44+
path: string,
45+
body: TBody,
46+
): Promise<APIResponse<TData, TIncluded>>;
47+
protected async patch<TData, TBody, TIncluded>(
48+
path: string,
49+
body: TBody,
50+
): Promise<APIResponse<TData, TIncluded>> {
51+
const url = new URL(path, this.baseUrl);
52+
53+
return this.request<TData, TIncluded>(url.toString(), {
54+
method: 'PATCH',
55+
body: JSON.stringify(body),
56+
});
57+
}
58+
3959
protected async download(url: string): Promise<Uint8Array> {
4060
const response = await fetch(url, {
4161
headers: {
@@ -54,9 +74,12 @@ export class BaseAPIClient {
5474

5575
private async request<TData, TIncluded = never>(
5676
url: string,
77+
init?: RequestInit,
5778
): Promise<APIResponse<TData, TIncluded>> {
5879
const response = await fetch(url, {
80+
...init,
5981
headers: {
82+
...(init?.headers ?? {}),
6083
Authorization: `Bearer ${this.auth.getToken()}`,
6184
'Content-Type': 'application/json',
6285
},

src/api/resources/workflows.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { BaseAPIClient } from '../base-client.js';
2-
import type { CiWorkflow, WorkflowIncludedResource } from '../types.js';
2+
import type {
3+
CiWorkflow,
4+
CiWorkflowAction,
5+
WorkflowIncludedResource,
6+
} from '../types.js';
7+
8+
type WorkflowAttributeUpdate = Partial<CiWorkflow['attributes']>;
39

410
/**
511
* Workflow endpoints.
@@ -40,4 +46,80 @@ export class WorkflowsClient extends BaseAPIClient {
4046
included: response.included ?? [],
4147
};
4248
}
49+
50+
/**
51+
* Update one workflow.
52+
*/
53+
async updateById(
54+
workflowId: string,
55+
attributes: WorkflowAttributeUpdate,
56+
): Promise<CiWorkflow> {
57+
const response = await this.patch<
58+
CiWorkflow,
59+
{
60+
data: {
61+
type: 'ciWorkflows';
62+
id: string;
63+
attributes: WorkflowAttributeUpdate;
64+
};
65+
}
66+
>(`/v1/ciWorkflows/${workflowId}`, {
67+
data: {
68+
type: 'ciWorkflows',
69+
id: workflowId,
70+
attributes,
71+
},
72+
});
73+
74+
return response.data;
75+
}
76+
77+
/**
78+
* Enable or disable one workflow.
79+
*/
80+
async setEnabled(workflowId: string, isEnabled: boolean): Promise<CiWorkflow> {
81+
return this.updateById(workflowId, { isEnabled });
82+
}
83+
84+
/**
85+
* Update general workflow fields.
86+
*/
87+
async updateGeneral(
88+
workflowId: string,
89+
attributes: Pick<
90+
WorkflowAttributeUpdate,
91+
'clean' | 'description' | 'name'
92+
>,
93+
): Promise<CiWorkflow> {
94+
return this.updateById(workflowId, attributes);
95+
}
96+
97+
/**
98+
* Update workflow start conditions.
99+
*/
100+
async updateStartConditions(
101+
workflowId: string,
102+
attributes: Pick<
103+
WorkflowAttributeUpdate,
104+
| 'branchStartCondition'
105+
| 'manualBranchStartCondition'
106+
| 'manualPullRequestStartCondition'
107+
| 'manualTagStartCondition'
108+
| 'pullRequestStartCondition'
109+
| 'scheduledStartCondition'
110+
| 'tagStartCondition'
111+
>,
112+
): Promise<CiWorkflow> {
113+
return this.updateById(workflowId, attributes);
114+
}
115+
116+
/**
117+
* Replace workflow actions explicitly.
118+
*/
119+
async updateActions(
120+
workflowId: string,
121+
actions: CiWorkflowAction[],
122+
): Promise<CiWorkflow> {
123+
return this.updateById(workflowId, { actions });
124+
}
43125
}

src/api/types.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,14 @@ export interface CiWorkflow {
5454
isLockedForEditing?: boolean;
5555
clean: boolean;
5656
containerFilePath: string;
57-
lastModifiedDate: string;
58-
branchStartCondition?: Record<string, unknown>;
59-
manualBranchStartCondition?: Record<string, unknown>;
60-
pullRequestStartCondition?: Record<string, unknown>;
61-
manualPullRequestStartCondition?: Record<string, unknown>;
62-
tagStartCondition?: Record<string, unknown>;
63-
manualTagStartCondition?: Record<string, unknown>;
64-
scheduledStartCondition?: Record<string, unknown>;
57+
lastModifiedDate: string | null;
58+
branchStartCondition?: Record<string, unknown> | null;
59+
manualBranchStartCondition?: Record<string, unknown> | null;
60+
pullRequestStartCondition?: Record<string, unknown> | null;
61+
manualPullRequestStartCondition?: Record<string, unknown> | null;
62+
tagStartCondition?: Record<string, unknown> | null;
63+
manualTagStartCondition?: Record<string, unknown> | null;
64+
scheduledStartCondition?: Record<string, unknown> | null;
6565
actions?: CiWorkflowAction[];
6666
};
6767
relationships?: {

src/tools/discovery.ts

Lines changed: 2 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
import { z } from 'zod';
22
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
33
import type { AppStoreConnectClient } from '../api/client.js';
4-
import type {
5-
CiMacOsVersion,
6-
CiWorkflowAction,
7-
CiXcodeVersion,
8-
ScmRepository,
9-
WorkflowIncludedResource,
10-
} from '../api/types.js';
114
import { parseIdentifier } from '../utils/identifiers.js';
125
import { errorResponse, jsonResponse } from '../utils/tool-response.js';
6+
import { formatWorkflowDetailsResponse } from '../utils/workflow-details.js';
137

148
/**
159
* Register discovery tools.
@@ -91,117 +85,10 @@ export function registerDiscoveryTools(
9185
try {
9286
const workflowIdentifier = parseIdentifier(workflowId, 'workflow');
9387
const { workflow, included } = await client.workflows.getById(workflowIdentifier);
94-
const repository = findIncludedResource<ScmRepository>(
95-
included,
96-
workflow.relationships?.repository?.data?.id,
97-
'scmRepositories',
98-
);
99-
const xcodeVersion = findIncludedResource<CiXcodeVersion>(
100-
included,
101-
workflow.relationships?.xcodeVersion?.data?.id,
102-
'ciXcodeVersions',
103-
);
104-
const macOsVersion = findIncludedResource<CiMacOsVersion>(
105-
included,
106-
workflow.relationships?.macOsVersion?.data?.id,
107-
'ciMacOsVersions',
108-
);
109-
const actions = workflow.attributes.actions ?? [];
110-
111-
return jsonResponse({
112-
workflow: {
113-
id: workflow.id,
114-
general: {
115-
name: workflow.attributes.name,
116-
description: workflow.attributes.description ?? null,
117-
isEnabled: workflow.attributes.isEnabled,
118-
isLockedForEditing: workflow.attributes.isLockedForEditing ?? null,
119-
clean: workflow.attributes.clean,
120-
containerFilePath: workflow.attributes.containerFilePath,
121-
lastModifiedDate: workflow.attributes.lastModifiedDate,
122-
},
123-
environment: {
124-
repository: repository
125-
? {
126-
id: repository.id,
127-
ownerName: repository.attributes.ownerName ?? null,
128-
repositoryName: repository.attributes.repositoryName ?? null,
129-
scmProvider: repository.attributes.scmProvider ?? null,
130-
defaultBranch: repository.attributes.defaultBranch ?? null,
131-
httpCloneUrl: repository.attributes.httpCloneUrl ?? null,
132-
sshCloneUrl: repository.attributes.sshCloneUrl ?? null,
133-
}
134-
: null,
135-
xcodeVersion: xcodeVersion
136-
? {
137-
id: xcodeVersion.id,
138-
name: xcodeVersion.attributes.name ?? null,
139-
version: xcodeVersion.attributes.version ?? null,
140-
supportedTestDestinations:
141-
xcodeVersion.attributes.testDestinations?.length ?? 0,
142-
}
143-
: null,
144-
macOsVersion: macOsVersion
145-
? {
146-
id: macOsVersion.id,
147-
name: macOsVersion.attributes.name ?? null,
148-
version: macOsVersion.attributes.version ?? null,
149-
}
150-
: null,
151-
},
152-
startConditions: {
153-
branch: workflow.attributes.branchStartCondition ?? null,
154-
manualBranch: workflow.attributes.manualBranchStartCondition ?? null,
155-
pullRequest: workflow.attributes.pullRequestStartCondition ?? null,
156-
manualPullRequest:
157-
workflow.attributes.manualPullRequestStartCondition ?? null,
158-
scheduled: workflow.attributes.scheduledStartCondition ?? null,
159-
tag: workflow.attributes.tagStartCondition ?? null,
160-
manualTag: workflow.attributes.manualTagStartCondition ?? null,
161-
},
162-
actions: actions.map(formatWorkflowAction),
163-
postActions: [],
164-
postActionsNote:
165-
'The App Store Connect workflow payload did not expose separate post-actions, so this field is empty unless Apple adds that data.',
166-
},
167-
});
88+
return jsonResponse(formatWorkflowDetailsResponse(workflow, included));
16889
} catch (error) {
16990
return errorResponse(error);
17091
}
17192
},
17293
);
17394
}
174-
175-
function findIncludedResource<TResource extends WorkflowIncludedResource>(
176-
resources: WorkflowIncludedResource[],
177-
identifier: string | undefined,
178-
type: TResource['type'],
179-
): TResource | undefined {
180-
if (!identifier) {
181-
return undefined;
182-
}
183-
184-
return resources.find(
185-
(resource): resource is TResource =>
186-
resource.id === identifier && resource.type === type,
187-
);
188-
}
189-
190-
function formatWorkflowAction(action: CiWorkflowAction) {
191-
return {
192-
name: action.name,
193-
actionType: action.actionType,
194-
platform: action.platform ?? null,
195-
scheme: action.scheme ?? null,
196-
destination: action.destination ?? null,
197-
buildDistributionAudience: action.buildDistributionAudience ?? null,
198-
isRequiredToPass: action.isRequiredToPass ?? null,
199-
testConfiguration: action.testConfiguration
200-
? {
201-
kind: action.testConfiguration.kind ?? null,
202-
testPlanName: action.testConfiguration.testPlanName ?? null,
203-
testDestinations: action.testConfiguration.testDestinations ?? [],
204-
}
205-
: null,
206-
};
207-
}

0 commit comments

Comments
 (0)