Skip to content

Commit 97ebb8a

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 d94b96a commit 97ebb8a

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
@@ -45,6 +45,11 @@ schemaVersion: 1
4545

4646
# Workflow selection
4747
enabledWorkflows: ["simulator", "ui-automation", "debugging"]
48+
customWorkflows:
49+
my-workflow:
50+
- build_run_sim
51+
- record_sim_video
52+
- screenshot
4853
experimentalWorkflowDiscovery: false
4954

5055
# Session defaults
@@ -154,6 +159,25 @@ enabledWorkflows: ["simulator", "ui-automation", "debugging"]
154159

155160
See [TOOLS.md](TOOLS.md) for available workflows and their tools.
156161

162+
### Custom workflows
163+
164+
You can define your own workflow names in config and reference them from `enabledWorkflows`.
165+
Each custom workflow is a list of tool names (MCP names), and only those tools are loaded for that workflow.
166+
167+
```yaml
168+
enabledWorkflows: ["my-workflow"]
169+
customWorkflows:
170+
my-workflow:
171+
- build_run_sim
172+
- record_sim_video
173+
- screenshot
174+
```
175+
176+
Notes:
177+
- Built-in implicit workflows are unchanged. Session-management tools are still auto-included, and the doctor workflow is still auto-included when `debug: true`.
178+
- Custom workflow names are normalized to lowercase.
179+
- Unknown tool names are ignored and logged as warnings.
180+
157181
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).
158182

159183
### Experimental workflow discovery
@@ -293,6 +317,7 @@ Notes:
293317
|--------|------|---------|
294318
| `schemaVersion` | number | Required (`1`) |
295319
| `enabledWorkflows` | string[] | `["simulator"]` |
320+
| `customWorkflows` | Record<string, string[]> | `{}` |
296321
| `experimentalWorkflowDiscovery` | boolean | `false` |
297322
| `disableSessionDefaults` | boolean | `false` |
298323
| `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
@@ -123,6 +123,35 @@ describe('config-store', () => {
123123
expect(updated.enabledWorkflows).toEqual(['device']);
124124
});
125125

126+
it('resolves customWorkflows from overrides, config, then defaults', async () => {
127+
const yaml = [
128+
'schemaVersion: 1',
129+
'customWorkflows:',
130+
' smoke:',
131+
' - build_run_sim',
132+
'',
133+
].join('\n');
134+
135+
await initConfigStore({ cwd, fs: createFs(yaml) });
136+
expect(getConfig().customWorkflows).toEqual({
137+
smoke: ['build_run_sim'],
138+
});
139+
140+
await initConfigStore({
141+
cwd,
142+
fs: createFs(yaml),
143+
overrides: {
144+
customWorkflows: {
145+
quick: ['screenshot'],
146+
},
147+
},
148+
});
149+
150+
expect(getConfig().customWorkflows).toEqual({
151+
quick: ['screenshot'],
152+
});
153+
});
154+
126155
it('merges namespaced session defaults profiles from file and overrides', async () => {
127156
const yaml = [
128157
'schemaVersion: 1',

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

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

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

118+
it('normalizes custom workflow entries while loading config', async () => {
119+
const yaml = [
120+
'schemaVersion: 1',
121+
'customWorkflows:',
122+
' valid-workflow:',
123+
' - build_run_sim',
124+
' invalid-workflow: build_run_sim',
125+
' "":',
126+
' - screenshot',
127+
'',
128+
].join('\n');
129+
130+
const { fs } = createFsFixture({ exists: true, readFile: yaml });
131+
const result = await loadProjectConfig({ fs, cwd });
132+
133+
if (!result.found) throw new Error('expected config to be found');
134+
135+
expect(result.config.customWorkflows).toEqual({
136+
'invalid-workflow': ['build_run_sim'],
137+
'valid-workflow': ['build_run_sim'],
138+
});
139+
});
140+
111141
it('should resolve file URLs in session defaults and top-level paths', async () => {
112142
const yaml = [
113143
'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
sentryDisabled: boolean;
1819
experimentalWorkflowDiscovery: boolean;
@@ -36,6 +37,7 @@ export type RuntimeConfigOverrides = Partial<{
3637

3738
export type ResolvedRuntimeConfig = {
3839
enabledWorkflows: string[];
40+
customWorkflows: Record<string, string[]>;
3941
debug: boolean;
4042
sentryDisabled: boolean;
4143
experimentalWorkflowDiscovery: boolean;
@@ -68,6 +70,7 @@ type ConfigStoreState = {
6870

6971
const DEFAULT_CONFIG: ResolvedRuntimeConfig = {
7072
enabledWorkflows: [],
73+
customWorkflows: {},
7174
debug: false,
7275
sentryDisabled: false,
7376
experimentalWorkflowDiscovery: false,
@@ -381,6 +384,13 @@ function resolveConfig(opts: {
381384
envConfig,
382385
fallback: DEFAULT_CONFIG.enabledWorkflows,
383386
}),
387+
customWorkflows: resolveFromLayers<Record<string, string[]>>({
388+
key: 'customWorkflows',
389+
overrides: opts.overrides,
390+
fileConfig: opts.fileConfig,
391+
envConfig,
392+
fallback: DEFAULT_CONFIG.customWorkflows,
393+
}),
384394
debug: resolveFromLayers({
385395
key: 'debug',
386396
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
};
@@ -167,6 +168,37 @@ function normalizeEnabledWorkflows(value: unknown): string[] {
167168
return [];
168169
}
169170

171+
function normalizeCustomWorkflows(value: unknown): Record<string, string[]> {
172+
if (!isPlainObject(value)) {
173+
return {};
174+
}
175+
176+
const normalized: Record<string, string[]> = {};
177+
178+
for (const [workflowName, workflowTools] of Object.entries(value)) {
179+
const normalizedWorkflowName = workflowName.trim().toLowerCase();
180+
if (!normalizedWorkflowName) {
181+
continue;
182+
}
183+
if (Array.isArray(workflowTools)) {
184+
normalized[normalizedWorkflowName] = workflowTools
185+
.filter((toolName): toolName is string => typeof toolName === 'string')
186+
.map((toolName) => toolName.trim().toLowerCase())
187+
.filter(Boolean);
188+
continue;
189+
}
190+
if (typeof workflowTools === 'string') {
191+
normalized[normalizedWorkflowName] = workflowTools
192+
.split(',')
193+
.map((toolName) => toolName.trim().toLowerCase())
194+
.filter(Boolean);
195+
continue;
196+
}
197+
}
198+
199+
return normalized;
200+
}
201+
170202
function resolveRelativeTopLevelPaths(config: ProjectConfig, cwd: string): ProjectConfig {
171203
const resolved: ProjectConfig = { ...config };
172204
const pathKeys = ['axePath', 'iosTemplatePath', 'macosTemplatePath'] as const;
@@ -211,12 +243,14 @@ function normalizeDebuggerBackend(config: RuntimeConfigFile): ProjectConfig {
211243
}
212244

213245
function normalizeConfigForPersistence(config: RuntimeConfigFile): ProjectConfig {
214-
const base = normalizeDebuggerBackend(config);
215-
if (config.enabledWorkflows === undefined) {
216-
return base;
246+
let base = normalizeDebuggerBackend(config);
247+
if (config.enabledWorkflows !== undefined) {
248+
base = { ...base, enabledWorkflows: normalizeEnabledWorkflows(config.enabledWorkflows) };
249+
}
250+
if (config.customWorkflows !== undefined) {
251+
base = { ...base, customWorkflows: normalizeCustomWorkflows(config.customWorkflows) };
217252
}
218-
const normalizedWorkflows = normalizeEnabledWorkflows(config.enabledWorkflows);
219-
return { ...base, enabledWorkflows: normalizedWorkflows };
253+
return base;
220254
}
221255

222256
function toProjectConfig(config: RuntimeConfigFile): ProjectConfig {
@@ -272,6 +306,10 @@ export async function loadProjectConfig(
272306
const normalizedWorkflows = normalizeEnabledWorkflows(parsed.enabledWorkflows);
273307
config = { ...config, enabledWorkflows: normalizedWorkflows };
274308
}
309+
if (parsed.customWorkflows !== undefined) {
310+
const normalizedCustomWorkflows = normalizeCustomWorkflows(parsed.customWorkflows);
311+
config = { ...config, customWorkflows: normalizedCustomWorkflows };
312+
}
275313

276314
if (config.sessionDefaults) {
277315
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
sentryDisabled: z.boolean().optional(),
1011
experimentalWorkflowDiscovery: z.boolean().optional(),

0 commit comments

Comments
 (0)