Skip to content

Commit 86e527f

Browse files
committed
Add better session default documentation and auto-reconsile xor conflicts gracefully
1 parent 96da376 commit 86e527f

12 files changed

Lines changed: 343 additions & 214 deletions

File tree

.smithery/index.cjs

Lines changed: 159 additions & 154 deletions
Large diffs are not rendered by default.

docs/TOOLS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,4 @@ XcodeBuildMCP provides 70 tools organized into 13 workflow groups for comprehens
121121

122122
---
123123

124-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-02*
124+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-03*

src/mcp/tools/device/__tests__/list_devices.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ describe('list_devices plugin (device-shared)', () => {
231231
content: [
232232
{
233233
type: 'text',
234-
text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\n",
234+
text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n",
235235
},
236236
],
237237
});

src/mcp/tools/device/list_devices.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ export async function list_devicesLogic(
395395
responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n";
396396
responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n";
397397
responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n';
398+
responseText +=
399+
"Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n";
398400
} else if (uniqueDevices.length > 0) {
399401
responseText +=
400402
'Note: No devices are currently available for testing. Make sure devices are:\n';

src/mcp/tools/project-discovery/__tests__/discover_projs.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ describe('discover_projs plugin', () => {
164164
{ type: 'text', text: 'Discovery finished. Found 1 projects and 1 workspaces.' },
165165
{ type: 'text', text: 'Projects found:\n - /workspace/MyApp.xcodeproj' },
166166
{ type: 'text', text: 'Workspaces found:\n - /workspace/MyWorkspace.xcworkspace' },
167+
{
168+
type: 'text',
169+
text: "Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.",
170+
},
167171
],
168172
isError: false,
169173
});

src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ describe('list_schemes plugin', () => {
7676
or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" })
7777
2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`,
7878
},
79+
{
80+
type: 'text',
81+
text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.',
82+
},
7983
],
8084
isError: false,
8185
});
@@ -295,6 +299,10 @@ describe('list_schemes plugin', () => {
295299
or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" })
296300
2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`,
297301
},
302+
{
303+
type: 'text',
304+
text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.',
305+
},
298306
],
299307
isError: false,
300308
});

src/mcp/tools/project-discovery/discover_projs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ export async function discover_projsLogic(
263263
);
264264
}
265265

266+
if (results.projects.length > 0 || results.workspaces.length > 0) {
267+
responseContent.push(
268+
createTextContent(
269+
"Hint: Save a default with session-set-defaults { projectPath: '...' } or { workspacePath: '...' }.",
270+
),
271+
);
272+
}
273+
266274
return {
267275
content: responseContent,
268276
isError: false,

src/mcp/tools/project-discovery/list_schemes.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const listSchemesSchema = z.preprocess(
3636

3737
export type ListSchemesParams = z.infer<typeof listSchemesSchema>;
3838

39+
const createTextBlock = (text: string) => ({ type: 'text', text }) as const;
40+
3941
/**
4042
* Business logic for listing schemes in a project or workspace.
4143
* Exported for direct testing and reuse.
@@ -79,6 +81,7 @@ export async function listSchemesLogic(
7981

8082
// Prepare next steps with the first scheme if available
8183
let nextStepsText = '';
84+
let hintText = '';
8285
if (schemes.length > 0) {
8386
const firstScheme = schemes[0];
8487

@@ -87,23 +90,23 @@ export async function listSchemesLogic(
8790
1. Build the app: build_macos({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })
8891
or for iOS: build_sim({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" })
8992
2. Show build settings: show_build_settings({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`;
93+
94+
hintText =
95+
`Hint: Consider saving a default scheme with session-set-defaults ` +
96+
`{ scheme: "${firstScheme}" } to avoid repeating it.`;
97+
}
98+
99+
const content = [
100+
createTextBlock('✅ Available schemes:'),
101+
createTextBlock(schemes.join('\n')),
102+
createTextBlock(nextStepsText),
103+
];
104+
if (hintText.length > 0) {
105+
content.push(createTextBlock(hintText));
90106
}
91107

92108
return {
93-
content: [
94-
{
95-
type: 'text',
96-
text: `✅ Available schemes:`,
97-
},
98-
{
99-
type: 'text',
100-
text: schemes.join('\n'),
101-
},
102-
{
103-
type: 'text',
104-
text: nextStepsText,
105-
},
106-
],
109+
content,
107110
isError: false,
108111
};
109112
} catch (error) {

src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,54 +59,72 @@ describe('session-set-defaults tool', () => {
5959

6060
it('should clear workspacePath when projectPath is set', async () => {
6161
sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' });
62-
await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' });
62+
const result = await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' });
6363
const current = sessionStore.getAll();
6464
expect(current.projectPath).toBe('/new/App.xcodeproj');
6565
expect(current.workspacePath).toBeUndefined();
66+
expect(result.content[0].text).toContain(
67+
'Cleared workspacePath because projectPath was set.',
68+
);
6669
});
6770

6871
it('should clear projectPath when workspacePath is set', async () => {
6972
sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' });
70-
await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' });
73+
const result = await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' });
7174
const current = sessionStore.getAll();
7275
expect(current.workspacePath).toBe('/new/App.xcworkspace');
7376
expect(current.projectPath).toBeUndefined();
77+
expect(result.content[0].text).toContain(
78+
'Cleared projectPath because workspacePath was set.',
79+
);
7480
});
7581

7682
it('should clear simulatorName when simulatorId is set', async () => {
7783
sessionStore.setDefaults({ simulatorName: 'iPhone 16' });
78-
await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' });
84+
const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' });
7985
const current = sessionStore.getAll();
8086
expect(current.simulatorId).toBe('SIM-UUID');
8187
expect(current.simulatorName).toBeUndefined();
88+
expect(result.content[0].text).toContain(
89+
'Cleared simulatorName because simulatorId was set.',
90+
);
8291
});
8392

8493
it('should clear simulatorId when simulatorName is set', async () => {
8594
sessionStore.setDefaults({ simulatorId: 'SIM-UUID' });
86-
await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' });
95+
const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' });
8796
const current = sessionStore.getAll();
8897
expect(current.simulatorName).toBe('iPhone 16');
8998
expect(current.simulatorId).toBeUndefined();
99+
expect(result.content[0].text).toContain(
100+
'Cleared simulatorId because simulatorName was set.',
101+
);
90102
});
91103

92-
it('should reject when both projectPath and workspacePath are provided', async () => {
93-
const res = await plugin.handler({
104+
it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => {
105+
const res = await sessionSetDefaultsLogic({
94106
projectPath: '/app/App.xcodeproj',
95107
workspacePath: '/app/App.xcworkspace',
96108
});
97-
expect(res.isError).toBe(true);
98-
expect(res.content[0].text).toContain('Parameter validation failed');
99-
expect(res.content[0].text).toContain('projectPath and workspacePath are mutually exclusive');
109+
const current = sessionStore.getAll();
110+
expect(current.workspacePath).toBe('/app/App.xcworkspace');
111+
expect(current.projectPath).toBeUndefined();
112+
expect(res.content[0].text).toContain(
113+
'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.',
114+
);
100115
});
101116

102-
it('should reject when both simulatorId and simulatorName are provided', async () => {
103-
const res = await plugin.handler({
117+
it('should prefer simulatorId when both simulatorId and simulatorName are provided', async () => {
118+
const res = await sessionSetDefaultsLogic({
104119
simulatorId: 'SIM-1',
105120
simulatorName: 'iPhone 16',
106121
});
107-
expect(res.isError).toBe(true);
108-
expect(res.content[0].text).toContain('Parameter validation failed');
109-
expect(res.content[0].text).toContain('simulatorId and simulatorName are mutually exclusive');
122+
const current = sessionStore.getAll();
123+
expect(current.simulatorId).toBe('SIM-1');
124+
expect(current.simulatorName).toBeUndefined();
125+
expect(res.content[0].text).toContain(
126+
'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.',
127+
);
110128
});
111129
});
112130
});

src/mcp/tools/session-management/session_set_defaults.ts

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,49 +5,124 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts';
55
import type { ToolResponse } from '../../../types/common.ts';
66

77
const baseSchema = z.object({
8-
projectPath: z.string().optional(),
9-
workspacePath: z.string().optional(),
10-
scheme: z.string().optional(),
11-
configuration: z.string().optional(),
12-
simulatorName: z.string().optional(),
13-
simulatorId: z.string().optional(),
14-
deviceId: z.string().optional(),
15-
useLatestOS: z.boolean().optional(),
16-
arch: z.enum(['arm64', 'x86_64']).optional(),
8+
projectPath: z
9+
.string()
10+
.optional()
11+
.describe(
12+
'Xcode project (.xcodeproj) path. Mutually exclusive with workspacePath. Required for most build/test tools when workspacePath is not set.',
13+
),
14+
workspacePath: z
15+
.string()
16+
.optional()
17+
.describe(
18+
'Xcode workspace (.xcworkspace) path. Mutually exclusive with projectPath. Required for most build/test tools when projectPath is not set.',
19+
),
20+
scheme: z
21+
.string()
22+
.optional()
23+
.describe(
24+
'Xcode scheme. Required by most build/test tools. Use list_schemes to discover available schemes before setting.',
25+
),
26+
configuration: z.string().optional().describe('Build configuration, e.g. Debug or Release.'),
27+
simulatorName: z
28+
.string()
29+
.optional()
30+
.describe(
31+
'Simulator device name for simulator workflows. If simulatorId is also provided, simulatorId is preferred and simulatorName is ignored.',
32+
),
33+
simulatorId: z
34+
.string()
35+
.optional()
36+
.describe(
37+
'Simulator UUID for simulator workflows. Preferred over simulatorName when both are provided.',
38+
),
39+
deviceId: z.string().optional().describe('Physical device ID for device workflows.'),
40+
useLatestOS: z
41+
.boolean()
42+
.optional()
43+
.describe('When true, prefer the latest available OS for simulatorName lookups.'),
44+
arch: z.enum(['arm64', 'x86_64']).optional().describe('Target architecture for macOS builds.'),
1745
suppressWarnings: z
1846
.boolean()
1947
.optional()
2048
.describe('When true, warning messages are filtered from build output to conserve context'),
2149
});
2250

23-
const schemaObj = baseSchema
24-
.refine((v) => !(v.projectPath && v.workspacePath), {
25-
message: 'projectPath and workspacePath are mutually exclusive',
26-
path: ['projectPath'],
27-
})
28-
.refine((v) => !(v.simulatorId && v.simulatorName), {
29-
message: 'simulatorId and simulatorName are mutually exclusive',
30-
path: ['simulatorId'],
31-
});
51+
const schemaObj = baseSchema;
3252

3353
type Params = z.infer<typeof schemaObj>;
3454

3555
export async function sessionSetDefaultsLogic(params: Params): Promise<ToolResponse> {
56+
const notices: string[] = [];
57+
const current = sessionStore.getAll();
58+
const nextParams: Partial<SessionDefaults> = { ...params };
59+
60+
const hasProjectPath =
61+
Object.prototype.hasOwnProperty.call(params, 'projectPath') && params.projectPath !== undefined;
62+
const hasWorkspacePath =
63+
Object.prototype.hasOwnProperty.call(params, 'workspacePath') &&
64+
params.workspacePath !== undefined;
65+
const hasSimulatorId =
66+
Object.prototype.hasOwnProperty.call(params, 'simulatorId') && params.simulatorId !== undefined;
67+
const hasSimulatorName =
68+
Object.prototype.hasOwnProperty.call(params, 'simulatorName') &&
69+
params.simulatorName !== undefined;
70+
71+
if (hasProjectPath && hasWorkspacePath) {
72+
delete nextParams.projectPath;
73+
notices.push(
74+
'Both projectPath and workspacePath were provided; keeping workspacePath and ignoring projectPath.',
75+
);
76+
}
77+
78+
if (hasSimulatorId && hasSimulatorName) {
79+
delete nextParams.simulatorName;
80+
notices.push(
81+
'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.',
82+
);
83+
}
84+
3685
// Clear mutually exclusive counterparts before merging new defaults
3786
const toClear = new Set<keyof SessionDefaults>();
38-
if (Object.prototype.hasOwnProperty.call(params, 'projectPath')) toClear.add('workspacePath');
39-
if (Object.prototype.hasOwnProperty.call(params, 'workspacePath')) toClear.add('projectPath');
40-
if (Object.prototype.hasOwnProperty.call(params, 'simulatorId')) toClear.add('simulatorName');
41-
if (Object.prototype.hasOwnProperty.call(params, 'simulatorName')) toClear.add('simulatorId');
87+
if (Object.prototype.hasOwnProperty.call(nextParams, 'projectPath')) {
88+
toClear.add('workspacePath');
89+
if (current.workspacePath !== undefined) {
90+
notices.push('Cleared workspacePath because projectPath was set.');
91+
}
92+
}
93+
if (Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath')) {
94+
toClear.add('projectPath');
95+
if (current.projectPath !== undefined) {
96+
notices.push('Cleared projectPath because workspacePath was set.');
97+
}
98+
}
99+
if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId')) {
100+
toClear.add('simulatorName');
101+
if (current.simulatorName !== undefined) {
102+
notices.push('Cleared simulatorName because simulatorId was set.');
103+
}
104+
}
105+
if (Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName')) {
106+
toClear.add('simulatorId');
107+
if (current.simulatorId !== undefined) {
108+
notices.push('Cleared simulatorId because simulatorName was set.');
109+
}
110+
}
42111

43112
if (toClear.size > 0) {
44113
sessionStore.clear(Array.from(toClear));
45114
}
46115

47-
sessionStore.setDefaults(params as Partial<SessionDefaults>);
48-
const current = sessionStore.getAll();
116+
sessionStore.setDefaults(nextParams as Partial<SessionDefaults>);
117+
const updated = sessionStore.getAll();
118+
const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : '';
49119
return {
50-
content: [{ type: 'text', text: `Defaults updated:\n${JSON.stringify(current, null, 2)}` }],
120+
content: [
121+
{
122+
type: 'text',
123+
text: `Defaults updated:\n${JSON.stringify(updated, null, 2)}${noticeText}`,
124+
},
125+
],
51126
isError: false,
52127
};
53128
}

0 commit comments

Comments
 (0)