Skip to content

Commit fc00659

Browse files
committed
feat: add customWorkflows support in config
Allow users to define named workflow groups in config.yaml mapping to explicit tool lists, then reference them from enabledWorkflows like built-in workflows. - New customWorkflows config field (schema, parsing, normalization) - Tool registry resolves custom workflow tool names to manifest IDs - Conflict detection for built-in workflow name collisions - Unknown tool names logged as warnings and skipped
1 parent f7b07ab commit fc00659

13 files changed

Lines changed: 348 additions & 11 deletions

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Added support for `customWorkflows` in `.xcodebuildmcp/config.yaml`, so user-defined workflow names can be referenced from `enabledWorkflows` and mapped to explicit tool lists.
8+
59
### Fixed
610

711
- Fixed `swift_package_build`, `swift_package_test`, and `swift_package_clean` swallowing compiler diagnostics on failure by treating empty stderr as falsy, so stdout diagnostics are included in the error response ([#243](https://github.com/getsentry/XcodeBuildMCP/issues/243)).
@@ -309,4 +313,3 @@ Please note that the UI automation features are an early preview and currently i
309313
- Initial release of XcodeBuildMCP
310314
- Basic support for building iOS and macOS applications
311315

312-

config.example.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
schemaVersion: 1
22
enabledWorkflows: ['simulator', 'ui-automation', 'debugging']
3+
customWorkflows:
4+
my-workflow:
5+
- build_run_sim
6+
- record_sim_video
7+
- screenshot
38
experimentalWorkflowDiscovery: false
49
disableSessionDefaults: false
510
incrementalBuildsEnabled: false

docs/CONFIGURATION.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ schemaVersion: 1
3939

4040
# Workflow selection
4141
enabledWorkflows: ["simulator", "ui-automation", "debugging"]
42+
customWorkflows:
43+
my-workflow:
44+
- build_run_sim
45+
- record_sim_video
46+
- screenshot
4247
experimentalWorkflowDiscovery: false
4348

4449
# Session defaults
@@ -147,6 +152,25 @@ enabledWorkflows: ["simulator", "ui-automation", "debugging"]
147152

148153
See [TOOLS.md](TOOLS.md) for available workflows and their tools.
149154

155+
### Custom workflows
156+
157+
You can define your own workflow names in config and reference them from `enabledWorkflows`.
158+
Each custom workflow is a list of tool names (MCP names), and only those tools are loaded for that workflow.
159+
160+
```yaml
161+
enabledWorkflows: ["my-workflow"]
162+
customWorkflows:
163+
my-workflow:
164+
- build_run_sim
165+
- record_sim_video
166+
- screenshot
167+
```
168+
169+
Notes:
170+
- Built-in implicit workflows are unchanged. Session-management tools are still auto-included, and the doctor workflow is still auto-included when `debug: true`.
171+
- Custom workflow names are normalized to lowercase.
172+
- Unknown tool names are ignored and logged as warnings.
173+
150174
To access Xcode IDE tools (Xcode 26+ `xcrun mcpbridge`), enable `xcode-ide`. This workflow exposes `xcode_ide_list_tools` and `xcode_ide_call_tool` for MCP clients. See [XCODE_IDE_MCPBRIDGE.md](XCODE_IDE_MCPBRIDGE.md).
151175

152176
### Experimental workflow discovery
@@ -281,6 +305,7 @@ Notes:
281305
|--------|------|---------|
282306
| `schemaVersion` | number | Required (`1`) |
283307
| `enabledWorkflows` | string[] | `["simulator"]` |
308+
| `customWorkflows` | Record<string, string[]> | `{}` |
284309
| `experimentalWorkflowDiscovery` | boolean | `false` |
285310
| `disableSessionDefaults` | boolean | `false` |
286311
| `sessionDefaults` | object | `{}` |

src/mcp/tools/workflow-discovery/__tests__/manage_workflows.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ vi.mock('../../../../utils/tool-registry.ts', () => ({
55
getRegisteredWorkflows: vi.fn(),
66
getMcpPredicateContext: vi.fn().mockReturnValue({
77
runtime: 'mcp',
8-
config: { debug: false },
8+
config: { debug: false, customWorkflows: {} },
99
runningUnderXcode: false,
1010
}),
1111
}));
@@ -15,6 +15,7 @@ vi.mock('../../../../utils/config-store.ts', () => ({
1515
debug: false,
1616
experimentalWorkflowDiscovery: false,
1717
enabledWorkflows: [],
18+
customWorkflows: {},
1819
}),
1920
}));
2021

src/utils/__tests__/config-store.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,35 @@ describe('config-store', () => {
112112
expect(updated.enabledWorkflows).toEqual(['device']);
113113
});
114114

115+
it('resolves customWorkflows from overrides, config, then defaults', async () => {
116+
const yaml = [
117+
'schemaVersion: 1',
118+
'customWorkflows:',
119+
' smoke:',
120+
' - build_run_sim',
121+
'',
122+
].join('\n');
123+
124+
await initConfigStore({ cwd, fs: createFs(yaml) });
125+
expect(getConfig().customWorkflows).toEqual({
126+
smoke: ['build_run_sim'],
127+
});
128+
129+
await initConfigStore({
130+
cwd,
131+
fs: createFs(yaml),
132+
overrides: {
133+
customWorkflows: {
134+
quick: ['screenshot'],
135+
},
136+
},
137+
});
138+
139+
expect(getConfig().customWorkflows).toEqual({
140+
quick: ['screenshot'],
141+
});
142+
});
143+
115144
it('merges namespaced session defaults profiles from file and overrides', async () => {
116145
const yaml = [
117146
'schemaVersion: 1',

src/utils/__tests__/project-config.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ describe('project-config', () => {
6060
const yaml = [
6161
'schemaVersion: 1',
6262
'enabledWorkflows: simulator,device',
63+
'customWorkflows:',
64+
' My-Workflow:',
65+
' - build_run_sim',
66+
' - SCREENSHOT',
6367
'debug: true',
6468
'axePath: "./bin/axe"',
6569
'sessionDefaults:',
@@ -78,6 +82,9 @@ describe('project-config', () => {
7882

7983
const defaults = result.config.sessionDefaults ?? {};
8084
expect(result.config.enabledWorkflows).toEqual(['simulator', 'device']);
85+
expect(result.config.customWorkflows).toEqual({
86+
'my-workflow': ['build_run_sim', 'screenshot'],
87+
});
8188
expect(result.config.debug).toBe(true);
8289
expect(result.config.axePath).toBe(path.join(cwd, 'bin', 'axe'));
8390
expect(defaults.workspacePath).toBe(path.join(cwd, 'App.xcworkspace'));
@@ -107,6 +114,29 @@ describe('project-config', () => {
107114
expect(result.config.macosTemplatePath).toBe('/opt/templates/macos');
108115
});
109116

117+
it('normalizes custom workflow entries while loading config', async () => {
118+
const yaml = [
119+
'schemaVersion: 1',
120+
'customWorkflows:',
121+
' valid-workflow:',
122+
' - build_run_sim',
123+
' invalid-workflow: build_run_sim',
124+
' "":',
125+
' - screenshot',
126+
'',
127+
].join('\n');
128+
129+
const { fs } = createFsFixture({ exists: true, readFile: yaml });
130+
const result = await loadProjectConfig({ fs, cwd });
131+
132+
if (!result.found) throw new Error('expected config to be found');
133+
134+
expect(result.config.customWorkflows).toEqual({
135+
'invalid-workflow': ['build_run_sim'],
136+
'valid-workflow': ['build_run_sim'],
137+
});
138+
});
139+
110140
it('should resolve file URLs in session defaults and top-level paths', async () => {
111141
const yaml = [
112142
'schemaVersion: 1',
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createCustomWorkflowsFromConfig } from '../tool-registry.ts';
3+
import type { ResolvedManifest } from '../../core/manifest/schema.ts';
4+
5+
function createManifestFixture(): ResolvedManifest {
6+
return {
7+
tools: new Map([
8+
[
9+
'build_run_sim',
10+
{
11+
id: 'build_run_sim',
12+
module: 'mcp/tools/simulator/build_run_sim',
13+
names: { mcp: 'build_run_sim' },
14+
availability: { mcp: true, cli: true },
15+
predicates: [],
16+
nextSteps: [],
17+
},
18+
],
19+
[
20+
'screenshot',
21+
{
22+
id: 'screenshot',
23+
module: 'mcp/tools/ui-automation/screenshot',
24+
names: { mcp: 'screenshot' },
25+
availability: { mcp: true, cli: true },
26+
predicates: [],
27+
nextSteps: [],
28+
},
29+
],
30+
]),
31+
workflows: new Map([
32+
[
33+
'simulator',
34+
{
35+
id: 'simulator',
36+
title: 'Simulator',
37+
description: 'Built-in simulator workflow',
38+
availability: { mcp: true, cli: true },
39+
predicates: [],
40+
tools: ['build_run_sim'],
41+
},
42+
],
43+
]),
44+
};
45+
}
46+
47+
describe('createCustomWorkflowsFromConfig', () => {
48+
it('creates custom workflows and resolves tool IDs', () => {
49+
const manifest = createManifestFixture();
50+
51+
const result = createCustomWorkflowsFromConfig(manifest, {
52+
'My-Workflow': ['build_run_sim', 'SCREENSHOT'],
53+
});
54+
55+
expect(result.workflows).toEqual([
56+
expect.objectContaining({
57+
id: 'my-workflow',
58+
tools: ['build_run_sim', 'screenshot'],
59+
}),
60+
]);
61+
expect(result.warnings).toEqual([]);
62+
});
63+
64+
it('warns when built-in workflow names conflict or tools are unknown', () => {
65+
const manifest = createManifestFixture();
66+
67+
const result = createCustomWorkflowsFromConfig(manifest, {
68+
simulator: ['build_run_sim'],
69+
quick: ['unknown_tool'],
70+
});
71+
72+
expect(result.workflows).toEqual([]);
73+
expect(result.warnings).toHaveLength(3);
74+
});
75+
});

src/utils/config-store.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.
1313

1414
export type RuntimeConfigOverrides = Partial<{
1515
enabledWorkflows: string[];
16+
customWorkflows: Record<string, string[]>;
1617
debug: boolean;
1718
experimentalWorkflowDiscovery: boolean;
1819
disableSessionDefaults: boolean;
@@ -35,6 +36,7 @@ export type RuntimeConfigOverrides = Partial<{
3536

3637
export type ResolvedRuntimeConfig = {
3738
enabledWorkflows: string[];
39+
customWorkflows: Record<string, string[]>;
3840
debug: boolean;
3941
experimentalWorkflowDiscovery: boolean;
4042
disableSessionDefaults: boolean;
@@ -66,6 +68,7 @@ type ConfigStoreState = {
6668

6769
const DEFAULT_CONFIG: ResolvedRuntimeConfig = {
6870
enabledWorkflows: [],
71+
customWorkflows: {},
6972
debug: false,
7073
experimentalWorkflowDiscovery: false,
7174
disableSessionDefaults: false,
@@ -377,6 +380,13 @@ function resolveConfig(opts: {
377380
envConfig,
378381
fallback: DEFAULT_CONFIG.enabledWorkflows,
379382
}),
383+
customWorkflows: resolveFromLayers<Record<string, string[]>>({
384+
key: 'customWorkflows',
385+
overrides: opts.overrides,
386+
fileConfig: opts.fileConfig,
387+
envConfig,
388+
fallback: DEFAULT_CONFIG.customWorkflows,
389+
}),
380390
debug: resolveFromLayers({
381391
key: 'debug',
382392
overrides: opts.overrides,

src/utils/project-config.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type ProjectConfig = RuntimeConfigFile & {
1717
sessionDefaultsProfiles?: Record<string, Partial<SessionDefaults>>;
1818
activeSessionDefaultsProfile?: string;
1919
enabledWorkflows?: string[];
20+
customWorkflows?: Record<string, string[]>;
2021
debuggerBackend?: 'dap' | 'lldb-cli';
2122
[key: string]: unknown;
2223
};
@@ -153,6 +154,37 @@ function normalizeEnabledWorkflows(value: unknown): string[] {
153154
return [];
154155
}
155156

157+
function normalizeCustomWorkflows(value: unknown): Record<string, string[]> {
158+
if (!isPlainObject(value)) {
159+
return {};
160+
}
161+
162+
const normalized: Record<string, string[]> = {};
163+
164+
for (const [workflowName, workflowTools] of Object.entries(value)) {
165+
const normalizedWorkflowName = workflowName.trim().toLowerCase();
166+
if (!normalizedWorkflowName) {
167+
continue;
168+
}
169+
if (Array.isArray(workflowTools)) {
170+
normalized[normalizedWorkflowName] = workflowTools
171+
.filter((toolName): toolName is string => typeof toolName === 'string')
172+
.map((toolName) => toolName.trim().toLowerCase())
173+
.filter(Boolean);
174+
continue;
175+
}
176+
if (typeof workflowTools === 'string') {
177+
normalized[normalizedWorkflowName] = workflowTools
178+
.split(',')
179+
.map((toolName) => toolName.trim().toLowerCase())
180+
.filter(Boolean);
181+
continue;
182+
}
183+
}
184+
185+
return normalized;
186+
}
187+
156188
function resolveRelativeTopLevelPaths(config: ProjectConfig, cwd: string): ProjectConfig {
157189
const resolved: ProjectConfig = { ...config };
158190
const pathKeys = ['axePath', 'iosTemplatePath', 'macosTemplatePath'] as const;
@@ -197,12 +229,14 @@ function normalizeDebuggerBackend(config: RuntimeConfigFile): ProjectConfig {
197229
}
198230

199231
function normalizeConfigForPersistence(config: RuntimeConfigFile): ProjectConfig {
200-
const base = normalizeDebuggerBackend(config);
201-
if (config.enabledWorkflows === undefined) {
202-
return base;
232+
let base = normalizeDebuggerBackend(config);
233+
if (config.enabledWorkflows !== undefined) {
234+
base = { ...base, enabledWorkflows: normalizeEnabledWorkflows(config.enabledWorkflows) };
235+
}
236+
if (config.customWorkflows !== undefined) {
237+
base = { ...base, customWorkflows: normalizeCustomWorkflows(config.customWorkflows) };
203238
}
204-
const normalizedWorkflows = normalizeEnabledWorkflows(config.enabledWorkflows);
205-
return { ...base, enabledWorkflows: normalizedWorkflows };
239+
return base;
206240
}
207241

208242
function toProjectConfig(config: RuntimeConfigFile): ProjectConfig {
@@ -258,6 +292,10 @@ export async function loadProjectConfig(
258292
const normalizedWorkflows = normalizeEnabledWorkflows(parsed.enabledWorkflows);
259293
config = { ...config, enabledWorkflows: normalizedWorkflows };
260294
}
295+
if (parsed.customWorkflows !== undefined) {
296+
const normalizedCustomWorkflows = normalizeCustomWorkflows(parsed.customWorkflows);
297+
config = { ...config, customWorkflows: normalizedCustomWorkflows };
298+
}
261299

262300
if (config.sessionDefaults) {
263301
const normalized = normalizeMutualExclusivity(config.sessionDefaults);

src/utils/runtime-config-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const runtimeConfigFileSchema = z
55
.object({
66
schemaVersion: z.literal(1).optional().default(1),
77
enabledWorkflows: z.union([z.array(z.string()), z.string()]).optional(),
8+
customWorkflows: z.record(z.string(), z.union([z.array(z.string()), z.string()])).optional(),
89
debug: z.boolean().optional(),
910
experimentalWorkflowDiscovery: z.boolean().optional(),
1011
disableSessionDefaults: z.boolean().optional(),

0 commit comments

Comments
 (0)