Skip to content

Commit 3885482

Browse files
cameroncookeclaude
andcommitted
fix(session-defaults): tighten clear semantics and profile handling
Make session_clear_defaults clear only the active profile by default, add\nexplicit profile targeting, and reserve all=true for full global+profile\nclears with guardrails.\n\nFix the session e2e profile flow to create named profiles intentionally and\nextract shared profile-name normalization to a single utility used by both\nconfig-store and project-config. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0c02529 commit 3885482

File tree

9 files changed

+136
-52
lines changed

9 files changed

+136
-52
lines changed

docs/TOOLS-CLI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,4 @@ XcodeBuildMCP provides 73 canonical tools organized into 13 workflow groups.
189189

190190
---
191191

192-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-17T21:23:33.036Z UTC*
192+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-17T21:48:36.993Z UTC*

docs/TOOLS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,4 @@ This document lists MCP tool names as exposed to MCP clients. XcodeBuildMCP prov
205205

206206
---
207207

208-
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-17T21:23:33.036Z UTC*
208+
*This documentation is automatically generated by `scripts/update-tools-docs.ts` from the tools manifest. Last updated: 2026-02-17T21:48:36.993Z UTC*

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

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,62 @@ describe('session-clear-defaults tool', () => {
4949
expect(current.arch).toBe('arm64');
5050
});
5151

52-
it('should clear all when all=true', async () => {
52+
it('should clear all profiles only when all=true', async () => {
5353
sessionStore.setActiveProfile('ios');
5454
sessionStore.setDefaults({ scheme: 'IOS' });
5555
sessionStore.setActiveProfile(null);
5656
const result = await sessionClearDefaultsLogic({ all: true });
5757
expect(result.isError).toBe(false);
58-
expect(result.content[0].text).toBe('Session defaults cleared');
58+
expect(result.content[0].text).toBe('All session defaults cleared');
5959

6060
const current = sessionStore.getAll();
6161
expect(Object.keys(current).length).toBe(0);
6262
expect(sessionStore.listProfiles()).toEqual([]);
6363
expect(sessionStore.getActiveProfile()).toBeNull();
6464
});
6565

66-
it('should clear all when no params provided', async () => {
66+
it('should clear only active profile when no params provided', async () => {
67+
sessionStore.setActiveProfile('ios');
68+
sessionStore.setDefaults({ scheme: 'IOS', projectPath: '/ios/project.xcodeproj' });
69+
sessionStore.setActiveProfile(null);
70+
sessionStore.setDefaults({ scheme: 'Global' });
71+
sessionStore.setActiveProfile('ios');
72+
6773
const result = await sessionClearDefaultsLogic({});
6874
expect(result.isError).toBe(false);
69-
const current = sessionStore.getAll();
70-
expect(Object.keys(current).length).toBe(0);
75+
76+
expect(sessionStore.getAll()).toEqual({});
77+
expect(sessionStore.listProfiles()).toEqual([]);
78+
79+
sessionStore.setActiveProfile(null);
80+
expect(sessionStore.getAll().scheme).toBe('Global');
81+
});
82+
83+
it('should clear a specific profile when profile is provided', async () => {
84+
sessionStore.setActiveProfile('ios');
85+
sessionStore.setDefaults({ scheme: 'IOS' });
86+
sessionStore.setActiveProfile('watch');
87+
sessionStore.setDefaults({ scheme: 'Watch' });
88+
sessionStore.setActiveProfile('watch');
89+
90+
const result = await sessionClearDefaultsLogic({ profile: 'ios' });
91+
expect(result.isError).toBe(false);
92+
expect(result.content[0].text).toContain('profile "ios"');
93+
94+
expect(sessionStore.listProfiles()).toEqual(['watch']);
95+
expect(sessionStore.getAll().scheme).toBe('Watch');
96+
});
97+
98+
it('should error when the specified profile does not exist', async () => {
99+
const result = await sessionClearDefaultsLogic({ profile: 'missing' });
100+
expect(result.isError).toBe(true);
101+
expect(result.content[0].text).toContain('does not exist');
102+
});
103+
104+
it('should reject all=true when combined with scoped arguments', async () => {
105+
const result = await sessionClearDefaultsLogic({ all: true, profile: 'ios' });
106+
expect(result.isError).toBe(true);
107+
expect(result.content[0].text).toContain('cannot be combined');
71108
});
72109

73110
it('should validate keys enum', async () => {

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

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,73 @@ const keys = sessionDefaultKeys;
99

1010
const schemaObj = z.object({
1111
keys: z.array(z.enum(keys)).optional(),
12-
all: z.boolean().optional(),
12+
profile: z
13+
.string()
14+
.min(1)
15+
.optional()
16+
.describe('Clear defaults for this named profile instead of the active profile.'),
17+
all: z
18+
.boolean()
19+
.optional()
20+
.describe(
21+
'Clear all defaults across global and named profiles. Cannot be combined with keys/profile.',
22+
),
1323
});
1424

1525
type Params = z.infer<typeof schemaObj>;
1626

1727
export async function sessionClearDefaultsLogic(params: Params): Promise<ToolResponse> {
18-
if (params.all || !params.keys) sessionStore.clear();
19-
else sessionStore.clear(params.keys);
28+
if (params.all) {
29+
if (params.profile !== undefined || params.keys !== undefined) {
30+
return {
31+
content: [
32+
{
33+
type: 'text',
34+
text: 'all=true cannot be combined with profile or keys.',
35+
},
36+
],
37+
isError: true,
38+
};
39+
}
40+
41+
sessionStore.clear();
42+
return { content: [{ type: 'text', text: 'All session defaults cleared' }], isError: false };
43+
}
44+
45+
const profile = params.profile?.trim();
46+
if (profile !== undefined) {
47+
if (profile.length === 0) {
48+
return {
49+
content: [{ type: 'text', text: 'Profile name cannot be empty.' }],
50+
isError: true,
51+
};
52+
}
53+
54+
if (!sessionStore.listProfiles().includes(profile)) {
55+
return {
56+
content: [{ type: 'text', text: `Profile "${profile}" does not exist.` }],
57+
isError: true,
58+
};
59+
}
60+
61+
if (params.keys) {
62+
sessionStore.clearForProfile(profile, params.keys);
63+
} else {
64+
sessionStore.clearForProfile(profile);
65+
}
66+
67+
return {
68+
content: [{ type: 'text', text: `Session defaults cleared for profile "${profile}"` }],
69+
isError: false,
70+
};
71+
}
72+
73+
if (params.keys) {
74+
sessionStore.clear(params.keys);
75+
} else {
76+
sessionStore.clearForProfile(sessionStore.getActiveProfile());
77+
}
78+
2079
return { content: [{ type: 'text', text: 'Session defaults cleared' }], isError: false };
2180
}
2281

src/smoke-tests/__tests__/e2e-mcp-sessions.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -179,22 +179,24 @@ describe('MCP Session Management (e2e)', () => {
179179
});
180180

181181
it('supports namespaced defaults by switching active profile', async () => {
182-
await harness.client.callTool({
183-
name: 'session_use_defaults_profile',
184-
arguments: { profile: 'ios' },
185-
});
186182
await harness.client.callTool({
187183
name: 'session_set_defaults',
188-
arguments: { scheme: 'IOSScheme', projectPath: '/ios/project.xcodeproj' },
184+
arguments: {
185+
profile: 'ios',
186+
createIfNotExists: true,
187+
scheme: 'IOSScheme',
188+
projectPath: '/ios/project.xcodeproj',
189+
},
189190
});
190191

191-
await harness.client.callTool({
192-
name: 'session_use_defaults_profile',
193-
arguments: { profile: 'watch' },
194-
});
195192
await harness.client.callTool({
196193
name: 'session_set_defaults',
197-
arguments: { scheme: 'WatchScheme', projectPath: '/watch/project.xcodeproj' },
194+
arguments: {
195+
profile: 'watch',
196+
createIfNotExists: true,
197+
scheme: 'WatchScheme',
198+
projectPath: '/watch/project.xcodeproj',
199+
},
198200
});
199201

200202
const activeWatch = await harness.client.callTool({

src/utils/config-store.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from './project-config.ts';
1010
import type { DebuggerBackendKind } from './debugger/types.ts';
1111
import type { UiDebuggerGuardMode } from './runtime-config-types.ts';
12+
import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts';
1213

1314
export type RuntimeConfigOverrides = Partial<{
1415
enabledWorkflows: string[];
@@ -319,19 +320,6 @@ function getCurrentFileConfig(): ProjectConfig {
319320
return storeState.fileConfig ?? { schemaVersion: 1 };
320321
}
321322

322-
function normalizeProfileNameForStore(profile?: string | null): string | null {
323-
if (profile == null) {
324-
return null;
325-
}
326-
327-
const trimmed = profile.trim();
328-
if (trimmed.length === 0) {
329-
throw new Error('Profile name cannot be empty.');
330-
}
331-
332-
return trimmed;
333-
}
334-
335323
function applySessionDefaultsPatchToFileConfig(opts: {
336324
fileConfig: ProjectConfig;
337325
profile: string | null;
@@ -562,7 +550,7 @@ export async function persistSessionDefaultsPatch(opts: {
562550
throw new Error('Config store has not been initialized.');
563551
}
564552

565-
const normalizedProfile = normalizeProfileNameForStore(opts.profile);
553+
const normalizedProfile = normalizeSessionDefaultsProfileName(opts.profile);
566554

567555
const result = await persistSessionDefaultsToProjectConfig({
568556
fs: storeState.fs,
@@ -590,7 +578,7 @@ export async function persistActiveSessionDefaultsProfile(
590578
throw new Error('Config store has not been initialized.');
591579
}
592580

593-
const normalizedProfile = normalizeProfileNameForStore(profile);
581+
const normalizedProfile = normalizeSessionDefaultsProfileName(profile);
594582

595583
const result = await persistActiveSessionDefaultsProfileToProjectConfig({
596584
fs: storeState.fs,

src/utils/project-config.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { SessionDefaults } from './session-store.ts';
66
import { log } from './logger.ts';
77
import { removeUndefined } from './remove-undefined.ts';
88
import { runtimeConfigFileSchema, type RuntimeConfigFile } from './runtime-config-schema.ts';
9+
import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts';
910

1011
const CONFIG_DIR = '.xcodebuildmcp';
1112
const CONFIG_FILE = 'config.yaml';
@@ -216,17 +217,6 @@ function parseProjectConfig(rawText: string): RuntimeConfigFile {
216217
return runtimeConfigFileSchema.parse(parsed) as RuntimeConfigFile;
217218
}
218219

219-
function normalizeProfileForPersistence(profile?: string | null): string | null {
220-
if (profile == null) {
221-
return null;
222-
}
223-
const trimmed = profile.trim();
224-
if (trimmed.length === 0) {
225-
throw new Error('Profile name cannot be empty.');
226-
}
227-
return trimmed;
228-
}
229-
230220
async function readBaseConfigForPersistence(
231221
options: PersistenceTargetOptions,
232222
): Promise<ProjectConfig> {
@@ -303,7 +293,7 @@ export async function persistSessionDefaultsToProjectConfig(
303293
const baseConfig = await readBaseConfigForPersistence({ fs: options.fs, configPath });
304294

305295
const patch = removeUndefined(options.patch as Record<string, unknown>);
306-
const targetProfile = normalizeProfileForPersistence(options.profile);
296+
const targetProfile = normalizeSessionDefaultsProfileName(options.profile);
307297
const isGlobalProfile = targetProfile === null;
308298
const baseDefaults = isGlobalProfile
309299
? (baseConfig.sessionDefaults ?? {})
@@ -341,7 +331,7 @@ export async function persistActiveSessionDefaultsProfileToProjectConfig(
341331
const baseConfig = await readBaseConfigForPersistence({ fs: options.fs, configPath });
342332

343333
const nextConfig: ProjectConfig = { ...baseConfig, schemaVersion: 1 };
344-
const activeProfile = normalizeProfileForPersistence(options.profile);
334+
const activeProfile = normalizeSessionDefaultsProfileName(options.profile);
345335
if (activeProfile === null) {
346336
delete nextConfig.activeSessionDefaultsProfile;
347337
} else {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function normalizeSessionDefaultsProfileName(profile?: string | null): string | null {
2+
if (profile == null) {
3+
return null;
4+
}
5+
6+
const trimmed = profile.trim();
7+
if (trimmed.length === 0) {
8+
throw new Error('Profile name cannot be empty.');
9+
}
10+
11+
return trimmed;
12+
}

src/utils/session-store.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,6 @@ class SessionStore {
112112
return this.getAllForProfile(this.activeProfile);
113113
}
114114

115-
getAllGlobal(): SessionDefaults {
116-
return { ...this.globalDefaults };
117-
}
118-
119115
getAllForProfile(profile: string | null): SessionDefaults {
120116
if (profile === null) {
121117
return { ...this.globalDefaults };

0 commit comments

Comments
 (0)