Skip to content

Commit fbc21e4

Browse files
committed
feat(cli): Hydrate tool args from session defaults
Use configured session defaults when building and executing CLI tool\ncommands so required flags become optional only when a matching\ndefault exists. Keep the normal missing-argument behavior when no\ndefault is available and keep session-management tools hidden from\nthe CLI surface.\n\nAdd a per-invocation --profile override so CLI commands can resolve\ndefaults from a named profile without mutating config or changing\nthe active profile. This keeps help output and invocation behavior\naligned while matching existing MCP profile semantics.\n\nCo-Authored-By: OpenAI <noreply@openai.com>
1 parent 7c04044 commit fbc21e4

11 files changed

Lines changed: 699 additions & 61 deletions

docs/TOOLS-CLI.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This document lists CLI tool names as exposed by `xcodebuildmcp <workflow> <tool>`.
44

5-
XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
5+
XcodeBuildMCP provides 76 canonical tools organized into 14 workflow groups.
66

77
## Workflow Groups
88

@@ -22,7 +22,7 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
2222

2323

2424
### iOS Device Development (`device`)
25-
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
25+
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (17 tools)
2626

2727
- `build` - Build for device.
2828
- `build-and-run` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
@@ -200,10 +200,10 @@ XcodeBuildMCP provides 75 canonical tools organized into 14 workflow groups.
200200

201201
## Summary Statistics
202202

203-
- **Canonical Tools**: 75
204-
- **Total Tools**: 107
203+
- **Canonical Tools**: 76
204+
- **Total Tools**: 108
205205
- **Workflow Groups**: 14
206206

207207
---
208208

209-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC*
209+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-07T20:17:02.262Z UTC*

docs/TOOLS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# XcodeBuildMCP MCP Tools Reference
22

3-
This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 81 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows.
3+
This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP provides 82 canonical tools organized into 16 workflow groups for comprehensive Apple development workflows.
44

55
## Workflow Groups
66

@@ -20,7 +20,7 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov
2020

2121

2222
### iOS Device Development (`device`)
23-
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (16 tools)
23+
**Purpose**: Complete iOS development workflow for physical devices (iPhone, iPad, Apple Watch, Apple TV, Apple Vision Pro). (17 tools)
2424

2525
- `build_device` - Build for device.
2626
- `build_run_device` - Build, install, and launch on physical device. Preferred single-step run tool when defaults are set.
@@ -216,10 +216,10 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov
216216

217217
## Summary Statistics
218218

219-
- **Canonical Tools**: 81
220-
- **Total Tools**: 113
219+
- **Canonical Tools**: 82
220+
- **Total Tools**: 114
221221
- **Workflow Groups**: 16
222222

223223
---
224224

225-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-03T09:47:33.422Z UTC*
225+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-03-07T20:17:02.262Z UTC*
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import yargs from 'yargs';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import * as z from 'zod';
4+
import type { ToolCatalog, ToolDefinition } from '../../runtime/types.ts';
5+
import { DefaultToolInvoker } from '../../runtime/tool-invoker.ts';
6+
import { createTextContent } from '../../types/common.ts';
7+
import type { ResolvedRuntimeConfig } from '../../utils/config-store.ts';
8+
import { registerToolCommands } from '../register-tool-commands.ts';
9+
10+
function createTool(overrides: Partial<ToolDefinition> = {}): ToolDefinition {
11+
return {
12+
cliName: 'run-tool',
13+
mcpName: 'run_tool',
14+
workflow: 'simulator',
15+
description: 'Run test tool',
16+
annotations: { readOnlyHint: true },
17+
cliSchema: {
18+
workspacePath: z.string().describe('Workspace path'),
19+
scheme: z.string().optional(),
20+
},
21+
mcpSchema: {
22+
workspacePath: z.string().describe('Workspace path'),
23+
scheme: z.string().optional(),
24+
},
25+
stateful: false,
26+
handler: vi.fn(async () => ({
27+
content: [createTextContent('ok')],
28+
isError: false,
29+
})),
30+
...overrides,
31+
};
32+
}
33+
34+
function createCatalog(tools: ToolDefinition[]): ToolCatalog {
35+
return {
36+
tools,
37+
getByCliName: (name) => tools.find((tool) => tool.cliName === name) ?? null,
38+
getByMcpName: (name) => tools.find((tool) => tool.mcpName === name) ?? null,
39+
getByToolId: (toolId) => tools.find((tool) => tool.id === toolId) ?? null,
40+
resolve: (input) => {
41+
const tool = tools.find((candidate) => candidate.cliName === input);
42+
return tool ? { tool } : { notFound: true };
43+
},
44+
};
45+
}
46+
47+
const baseRuntimeConfig: ResolvedRuntimeConfig = {
48+
enabledWorkflows: [],
49+
customWorkflows: {},
50+
debug: false,
51+
sentryDisabled: false,
52+
experimentalWorkflowDiscovery: false,
53+
disableSessionDefaults: true,
54+
disableXcodeAutoSync: false,
55+
uiDebuggerGuardMode: 'error',
56+
incrementalBuildsEnabled: false,
57+
dapRequestTimeoutMs: 30_000,
58+
dapLogEvents: false,
59+
launchJsonWaitMs: 8_000,
60+
debuggerBackend: 'dap',
61+
sessionDefaults: {
62+
workspacePath: 'App.xcworkspace',
63+
},
64+
sessionDefaultsProfiles: {
65+
ios: {
66+
workspacePath: 'Profile.xcworkspace',
67+
},
68+
},
69+
activeSessionDefaultsProfile: 'ios',
70+
};
71+
72+
function createApp(catalog: ToolCatalog, runtimeConfig: ResolvedRuntimeConfig = baseRuntimeConfig) {
73+
const app = yargs()
74+
.scriptName('xcodebuildmcp')
75+
.exitProcess(false)
76+
.fail((message, error) => {
77+
throw error ?? new Error(message);
78+
});
79+
80+
registerToolCommands(app, catalog, {
81+
workspaceRoot: '/repo',
82+
runtimeConfig,
83+
cliExposedWorkflowIds: ['simulator'],
84+
workflowNames: ['simulator'],
85+
});
86+
87+
return app;
88+
}
89+
90+
describe('registerToolCommands', () => {
91+
const originalArgv = process.argv;
92+
93+
afterEach(() => {
94+
vi.restoreAllMocks();
95+
process.exitCode = undefined;
96+
process.argv = originalArgv;
97+
});
98+
99+
it('hydrates required args from the active defaults profile', async () => {
100+
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
101+
content: [createTextContent('ok')],
102+
isError: false,
103+
});
104+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
105+
106+
const tool = createTool();
107+
const app = createApp(createCatalog([tool]));
108+
109+
await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();
110+
111+
expect(invokeDirect).toHaveBeenCalledTimes(1);
112+
expect(invokeDirect).toHaveBeenCalledWith(
113+
tool,
114+
{
115+
workspacePath: 'Profile.xcworkspace',
116+
},
117+
expect.objectContaining({
118+
runtime: 'cli',
119+
workspaceRoot: '/repo',
120+
}),
121+
);
122+
123+
stdoutWrite.mockRestore();
124+
});
125+
126+
it('hydrates required args from the explicit --profile override', async () => {
127+
process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa'];
128+
129+
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
130+
content: [createTextContent('ok')],
131+
isError: false,
132+
});
133+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
134+
135+
const tool = createTool();
136+
const app = createApp(createCatalog([tool]), {
137+
...baseRuntimeConfig,
138+
sessionDefaultsProfiles: {
139+
...baseRuntimeConfig.sessionDefaultsProfiles,
140+
qa: {
141+
workspacePath: 'QA.xcworkspace',
142+
},
143+
},
144+
});
145+
146+
await expect(
147+
app.parseAsync(['simulator', 'run-tool', '--profile', 'qa']),
148+
).resolves.toBeDefined();
149+
150+
expect(invokeDirect).toHaveBeenCalledWith(
151+
tool,
152+
{
153+
workspacePath: 'QA.xcworkspace',
154+
},
155+
expect.any(Object),
156+
);
157+
158+
stdoutWrite.mockRestore();
159+
});
160+
161+
it('keeps the normal missing-argument error when no hydrated default exists', async () => {
162+
const tool = createTool();
163+
const app = createApp(createCatalog([tool]), {
164+
...baseRuntimeConfig,
165+
sessionDefaults: undefined,
166+
sessionDefaultsProfiles: undefined,
167+
activeSessionDefaultsProfile: undefined,
168+
});
169+
170+
let error: Error | undefined;
171+
try {
172+
await app.parseAsync(['simulator', 'run-tool']);
173+
} catch (thrown) {
174+
error = thrown as Error;
175+
}
176+
177+
expect(error?.message).toContain('Missing required argument: workspace-path');
178+
expect(error?.message).not.toMatch(/session defaults/i);
179+
});
180+
181+
it('hydrates args before daemon-routed invocation', async () => {
182+
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
183+
content: [createTextContent('ok')],
184+
isError: false,
185+
});
186+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
187+
188+
const tool = createTool({ stateful: true });
189+
const app = createApp(createCatalog([tool]));
190+
191+
await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();
192+
193+
expect(invokeDirect).toHaveBeenCalledWith(
194+
tool,
195+
{
196+
workspacePath: 'Profile.xcworkspace',
197+
},
198+
expect.any(Object),
199+
);
200+
201+
stdoutWrite.mockRestore();
202+
});
203+
204+
it('lets explicit args override conflicting defaults before invocation', async () => {
205+
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
206+
content: [createTextContent('ok')],
207+
isError: false,
208+
});
209+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
210+
211+
const tool = createTool({
212+
cliSchema: {
213+
projectPath: z.string().describe('Project path'),
214+
workspacePath: z.string().optional(),
215+
},
216+
mcpSchema: {
217+
projectPath: z.string().describe('Project path'),
218+
workspacePath: z.string().optional(),
219+
},
220+
});
221+
const app = createApp(createCatalog([tool]));
222+
223+
await expect(
224+
app.parseAsync(['simulator', 'run-tool', '--project-path', 'App.xcodeproj']),
225+
).resolves.toBeDefined();
226+
227+
expect(invokeDirect).toHaveBeenCalledWith(
228+
tool,
229+
{
230+
projectPath: 'App.xcodeproj',
231+
},
232+
expect.any(Object),
233+
);
234+
235+
stdoutWrite.mockRestore();
236+
});
237+
238+
it('errors clearly when --profile references an unknown profile', async () => {
239+
const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
240+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
241+
242+
const tool = createTool();
243+
const app = createApp(createCatalog([tool]));
244+
245+
await expect(
246+
app.parseAsync(['simulator', 'run-tool', '--profile', 'missing']),
247+
).resolves.toBeDefined();
248+
249+
expect(consoleError).toHaveBeenCalledWith("Error: Unknown defaults profile 'missing'");
250+
expect(process.exitCode).toBe(1);
251+
252+
stderrWrite.mockRestore();
253+
});
254+
255+
it('lets --json override configured defaults', async () => {
256+
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
257+
content: [createTextContent('ok')],
258+
isError: false,
259+
});
260+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
261+
262+
const tool = createTool();
263+
const app = createApp(createCatalog([tool]));
264+
265+
await expect(
266+
app.parseAsync([
267+
'simulator',
268+
'run-tool',
269+
'--json',
270+
JSON.stringify({ workspacePath: 'Json.xcworkspace' }),
271+
]),
272+
).resolves.toBeDefined();
273+
274+
expect(invokeDirect).toHaveBeenCalledWith(
275+
tool,
276+
{
277+
workspacePath: 'Json.xcworkspace',
278+
},
279+
expect.any(Object),
280+
);
281+
282+
stdoutWrite.mockRestore();
283+
});
284+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
import * as z from 'zod';
3+
import { schemaToYargsOptions } from '../schema-to-yargs.ts';
4+
5+
describe('schemaToYargsOptions', () => {
6+
it('keeps required flags required when no hydrated default exists', () => {
7+
const options = schemaToYargsOptions({
8+
workspacePath: z.string().describe('Workspace path'),
9+
});
10+
11+
expect(options.get('workspace-path')?.demandOption).toBe(true);
12+
});
13+
14+
it('drops required flag demand when a hydrated default exists', () => {
15+
const options = schemaToYargsOptions(
16+
{
17+
workspacePath: z.string().describe('Workspace path'),
18+
},
19+
{
20+
hydratedDefaults: {
21+
workspacePath: 'App.xcworkspace',
22+
},
23+
},
24+
);
25+
26+
expect(options.get('workspace-path')?.demandOption).toBe(false);
27+
});
28+
});

0 commit comments

Comments
 (0)