diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index f1cb8c3b..f37435bd 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -258,6 +258,48 @@ describe('session-set-defaults tool', () => { expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); }); + it('should store env as a Record default', async () => { + const envVars = { STAGING_ENABLED: '1', DEBUG: 'true' }; + const result = await sessionSetDefaultsLogic({ env: envVars }, createContext()); + + expect(result.isError).toBe(false); + expect(sessionStore.getAll().env).toEqual(envVars); + }); + + it('should persist env to config when persist is true', async () => { + const yaml = ['schemaVersion: 1', 'sessionDefaults: {}', ''].join('\n'); + + const writes: { path: string; content: string }[] = []; + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + + await initConfigStore({ cwd, fs }); + + const envVars = { API_URL: 'https://staging.example.com' }; + const result = await sessionSetDefaultsLogic( + { env: envVars, persist: true }, + createContext(), + ); + + expect(result.content[0].text).toContain('Persisted defaults to'); + expect(writes.length).toBe(1); + + const parsed = parseYaml(writes[0].content) as { + sessionDefaults?: Record; + }; + expect(parsed.sessionDefaults?.env).toEqual(envVars); + }); + it('should not persist when persist is true but no defaults were provided', async () => { const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index feed93e5..dc8161ad 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -258,4 +258,36 @@ describe('createSessionAwareTool', () => { expect(parsed.simulatorId).toBe('SIM-123'); expect(parsed.simulatorName).toBeUndefined(); }); + + it('deep-merges env so user-provided env vars are additive with session defaults', async () => { + const envSchema = z.object({ + scheme: z.string(), + projectPath: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }); + + const envHandler = createSessionAwareTool>({ + internalSchema: envSchema, + logicFunction: async (params) => ({ + content: [{ type: 'text', text: JSON.stringify(params.env) }], + isError: false, + }), + getExecutor: () => createMockExecutor({ success: true }), + requirements: [{ allOf: ['scheme'] }], + }); + + sessionStore.setDefaults({ + scheme: 'App', + projectPath: '/a.xcodeproj', + env: { API_KEY: 'abc123', VERBOSE: '1' }, + }); + + // User provides additional env var; session default env vars should be preserved + const result = await envHandler({ env: { DEBUG: 'true', VERBOSE: '0' } }); + expect(result.isError).toBe(false); + + const envContent = result.content[0] as { type: 'text'; text: string }; + const parsed = JSON.parse(envContent.text); + expect(parsed).toEqual({ API_KEY: 'abc123', DEBUG: 'true', VERBOSE: '0' }); + }); }); diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts index 0383e86e..253c16f6 100644 --- a/src/utils/session-defaults-schema.ts +++ b/src/utils/session-defaults-schema.ts @@ -16,6 +16,7 @@ export const sessionDefaultKeys = [ 'preferXcodebuild', 'platform', 'bundleId', + 'env', ] as const; export type SessionDefaultKey = (typeof sessionDefaultKeys)[number]; @@ -54,4 +55,8 @@ export const sessionDefaultsSchema = z.object({ .string() .optional() .describe('Default bundle ID for launch/stop/log tools when working on a single app.'), + env: z + .record(z.string(), z.string()) + .optional() + .describe('Default environment variables to pass to launched apps.'), }); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 85ddf05c..548797d4 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -20,6 +20,7 @@ export type SessionDefaults = { preferXcodebuild?: boolean; platform?: string; bundleId?: string; + env?: Record; }; class SessionStore { diff --git a/src/utils/typed-tool-factory.ts b/src/utils/typed-tool-factory.ts index f6ddfe95..0c9593d7 100644 --- a/src/utils/typed-tool-factory.ts +++ b/src/utils/typed-tool-factory.ts @@ -163,7 +163,14 @@ function createSessionAwareHandler(opts: { } // Start with session defaults merged with explicit args (args override session) - const merged: Record = { ...sessionStore.getAll(), ...sanitizedArgs }; + const sessionDefaults = sessionStore.getAll(); + const merged: Record = { ...sessionDefaults, ...sanitizedArgs }; + + // Deep-merge env: combine session-default env vars with user-provided ones + // (user-provided keys take precedence on conflict) + if (sessionDefaults.env && typeof sanitizedArgs.env === 'object' && sanitizedArgs.env) { + merged.env = { ...sessionDefaults.env, ...(sanitizedArgs.env as Record) }; + } // Apply exclusive pair pruning: only when caller provided a concrete (non-null/undefined) value // for any key in the pair. When activated, drop other keys in the pair coming from session defaults. diff --git a/src/version.ts b/src/version.ts index 35404a97..255d996f 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,3 +1,3 @@ -export const version = '2.0.0-beta.1'; +export const version = '2.0.0'; export const iOSTemplateVersion = 'v1.0.8'; export const macOSTemplateVersion = 'v1.0.5';