Skip to content

feat(setup): Add env var session defaults and --format mcp-json#272

Merged
cameroncooke merged 6 commits intomainfrom
feat/env-var-session-defaults
Mar 12, 2026
Merged

feat(setup): Add env var session defaults and --format mcp-json#272
cameroncooke merged 6 commits intomainfrom
feat/env-var-session-defaults

Conversation

@cameroncooke
Copy link
Collaborator

@cameroncooke cameroncooke commented Mar 11, 2026

Add environment variable support for all 15 session default values (e.g. XCODEBUILDMCP_WORKSPACE_PATH, XCODEBUILDMCP_SCHEME) so MCP clients that cannot write config files can bootstrap defaults through their env config block.

Wire the env layer into resolveSessionDefaults() with precedence: tool overrides > config file > env vars.

Add --format mcp-json to xcodebuildmcp setup which outputs a ready-to-paste MCP client JSON config block instead of writing config.yaml. The setup wizard now collects simulator/device defaults based on selected workflows rather than output format, keeping yaml and mcp-json paths aligned.

Update CONFIGURATION.md to document env vars as the recommended approach for MCP client integration and describe the layering model.

Originally contributed by ichoosetoaccept

Closes #267

Ismar Iljazovic and others added 2 commits March 11, 2026 21:00
- Add readEnvSessionDefaults() to parse all 15 session default env vars
  (XCODEBUILDMCP_WORKSPACE_PATH, XCODEBUILDMCP_SCHEME, etc.)
- Wire env layer into resolveSessionDefaults() with precedence:
  tool overrides > config file > env vars
- Add --format mcp-json flag to xcodebuildmcp setup: outputs a
  ready-to-paste MCP client config JSON block instead of writing config.yaml
- Update CONFIGURATION.md: env vars documented as recommended method
  for MCP client integration, remove 'legacy' label, add layering section
- Add tests for env var parsing and file-over-env precedence
- Add test for --format mcp-json output

Closes #267
Collect simulator and device defaults based on the workflows a user
selects instead of tying setup questions to the output format. This
keeps yaml and mcp-json setup aligned and lets users skip pinning a
default target when they do not want one.

Reframe the docs so config.yaml remains the canonical config surface
and env vars are described as bootstrap values for constrained MCP
clients.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/xcodebuildmcp@272

commit: 6e3fb8c

Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Direct fs import bypasses injected FileSystemExecutor
    • Updated listAvailableDevices to accept FileSystemExecutor parameter and use its methods (mkdtemp, tmpdir, readFile, rm) instead of importing fs directly from node:fs.
  • ✅ Fixed: Env session defaults lost after session patch refresh
    • Added env field to ConfigStoreState, stored custom env in initConfigStore, and passed it through refreshResolvedSessionFields to preserve custom environment across session defaults updates.
  • ✅ Fixed: Test uses real filesystem for device list mock
    • Removed real fs import from test file and updated all tests to write device list data through the mock FileSystemExecutor instead of using real filesystem operations.

Create PR

Or push these changes by commenting:

@cursor push ec1a1e50e2
Preview (ec1a1e50e2)
diff --git a/src/cli/commands/__tests__/setup.test.ts b/src/cli/commands/__tests__/setup.test.ts
--- a/src/cli/commands/__tests__/setup.test.ts
+++ b/src/cli/commands/__tests__/setup.test.ts
@@ -1,5 +1,4 @@
 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { promises as fs } from 'node:fs';
 import path from 'node:path';
 import { parse as parseYaml } from 'yaml';
 import {
@@ -7,35 +6,31 @@
   createMockFileSystemExecutor,
 } from '../../../test-utils/mock-executors.ts';
 import type { CommandExecutor } from '../../../utils/CommandExecutor.ts';
+import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts';
 import type { Prompter } from '../../interactive/prompts.ts';
 import { runSetupWizard } from '../setup.ts';
 
 const cwd = '/repo';
 const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml');
 
-async function writeMockDeviceList(jsonPath: string): Promise<void> {
-  await fs.writeFile(
-    jsonPath,
-    JSON.stringify({
-      result: {
-        devices: [
-          {
-            identifier: 'DEVICE-1',
-            visibilityClass: 'Default',
-            connectionProperties: {
-              pairingState: 'paired',
-              tunnelState: 'connected',
-            },
-            deviceProperties: {
-              name: 'Cam iPhone',
-              platformIdentifier: 'com.apple.platform.iphoneos',
-            },
-          },
-        ],
+const mockDeviceListJson = JSON.stringify({
+  result: {
+    devices: [
+      {
+        identifier: 'DEVICE-1',
+        visibilityClass: 'Default',
+        connectionProperties: {
+          pairingState: 'paired',
+          tunnelState: 'connected',
+        },
+        deviceProperties: {
+          name: 'Cam iPhone',
+          platformIdentifier: 'com.apple.platform.iphoneos',
+        },
       },
-    }),
-  );
-}
+    ],
+  },
+});
 
 function createTestPrompter(): Prompter {
   return {
@@ -72,6 +67,7 @@
 
   it('exports a setup wizard that writes config selections', async () => {
     let storedConfig = '';
+    const deviceListFiles = new Map<string, string>();
 
     const fs = createMockFileSystemExecutor({
       existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0,
@@ -90,22 +86,31 @@
         return [];
       },
       readFile: async (targetPath) => {
-        if (targetPath !== configPath) {
-          throw new Error(`Unexpected read path: ${targetPath}`);
+        if (targetPath === configPath) {
+          return storedConfig;
         }
-        return storedConfig;
+        if (deviceListFiles.has(targetPath)) {
+          return deviceListFiles.get(targetPath)!;
+        }
+        throw new Error(`Unexpected read path: ${targetPath}`);
       },
       writeFile: async (targetPath, content) => {
-        if (targetPath !== configPath) {
-          throw new Error(`Unexpected write path: ${targetPath}`);
+        if (targetPath === configPath) {
+          storedConfig = content;
+          return;
         }
-        storedConfig = content;
+        deviceListFiles.set(targetPath, content);
       },
+      mkdtemp: async (prefix: string) => {
+        return `${prefix}123456`;
+      },
+      tmpdir: () => '/tmp',
+      rm: async () => {},
     });
 
     const executor: CommandExecutor = async (command) => {
       if (command[0] === 'xcrun' && command[1] === 'devicectl') {
-        await writeMockDeviceList(command[5]);
+        await fs.writeFile(command[5], mockDeviceListJson);
         return createMockCommandResponse({
           success: true,
           output: '',
@@ -172,6 +177,7 @@
   it('shows debug-gated workflows when existing config enables debug', async () => {
     let storedConfig = 'schemaVersion: 1\ndebug: true\n';
     let offeredWorkflowIds: string[] = [];
+    const deviceListFiles = new Map<string, string>();
 
     const fs = createMockFileSystemExecutor({
       existsSync: (targetPath) => targetPath === configPath && storedConfig.length > 0,
@@ -190,22 +196,31 @@
         return [];
       },
       readFile: async (targetPath) => {
-        if (targetPath !== configPath) {
-          throw new Error(`Unexpected read path: ${targetPath}`);
+        if (targetPath === configPath) {
+          return storedConfig;
         }
-        return storedConfig;
+        if (deviceListFiles.has(targetPath)) {
+          return deviceListFiles.get(targetPath)!;
+        }
+        throw new Error(`Unexpected read path: ${targetPath}`);
       },
       writeFile: async (targetPath, content) => {
-        if (targetPath !== configPath) {
-          throw new Error(`Unexpected write path: ${targetPath}`);
+        if (targetPath === configPath) {
+          storedConfig = content;
+          return;
         }
-        storedConfig = content;
+        deviceListFiles.set(targetPath, content);
       },
+      mkdtemp: async (prefix: string) => {
+        return `${prefix}123456`;
+      },
+      tmpdir: () => '/tmp',
+      rm: async () => {},
     });
 
     const executor: CommandExecutor = async (command) => {
       if (command[0] === 'xcrun' && command[1] === 'devicectl') {
-        await writeMockDeviceList(command[5]);
+        await fs.writeFile(command[5], mockDeviceListJson);
         return createMockCommandResponse({
           success: true,
           output: '',
@@ -297,6 +312,8 @@
   });
 
   it('outputs MCP config JSON when format is mcp-json', async () => {
+    const deviceListFiles = new Map<string, string>();
+
     const fs = createMockFileSystemExecutor({
       existsSync: () => false,
       stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
@@ -313,13 +330,25 @@
 
         return [];
       },
-      readFile: async () => '',
-      writeFile: async () => {},
+      readFile: async (targetPath) => {
+        if (deviceListFiles.has(targetPath)) {
+          return deviceListFiles.get(targetPath)!;
+        }
+        return '';
+      },
+      writeFile: async (targetPath, content) => {
+        deviceListFiles.set(targetPath, content);
+      },
+      mkdtemp: async (prefix: string) => {
+        return `${prefix}123456`;
+      },
+      tmpdir: () => '/tmp',
+      rm: async () => {},
     });
 
     const executor: CommandExecutor = async (command) => {
       if (command[0] === 'xcrun' && command[1] === 'devicectl') {
-        await writeMockDeviceList(command[5]);
+        await fs.writeFile(command[5], mockDeviceListJson);
         return createMockCommandResponse({
           success: true,
           output: '',
@@ -474,6 +503,8 @@
   });
 
   it('collects a device default without requiring simulator selection when only device-dependent workflows are enabled', async () => {
+    const deviceListFiles = new Map<string, string>();
+
     const fs = createMockFileSystemExecutor({
       existsSync: () => false,
       stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }),
@@ -490,8 +521,20 @@
 
         return [];
       },
-      readFile: async () => '',
-      writeFile: async () => {},
+      readFile: async (targetPath) => {
+        if (deviceListFiles.has(targetPath)) {
+          return deviceListFiles.get(targetPath)!;
+        }
+        return '';
+      },
+      writeFile: async (targetPath, content) => {
+        deviceListFiles.set(targetPath, content);
+      },
+      mkdtemp: async (prefix: string) => {
+        return `${prefix}123456`;
+      },
+      tmpdir: () => '/tmp',
+      rm: async () => {},
     });
 
     const executor: CommandExecutor = async (command) => {
@@ -500,7 +543,7 @@
       }
 
       if (command[0] === 'xcrun' && command[1] === 'devicectl') {
-        await writeMockDeviceList(command[5]);
+        await fs.writeFile(command[5], mockDeviceListJson);
         return createMockCommandResponse({
           success: true,
           output: '',

diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts
--- a/src/cli/commands/setup.ts
+++ b/src/cli/commands/setup.ts
@@ -1,6 +1,4 @@
 import type { Argv } from 'yargs';
-import { promises as fs } from 'node:fs';
-import { tmpdir } from 'node:os';
 import path from 'node:path';
 import * as clack from '@clack/prompts';
 import { getDefaultCommandExecutor, getDefaultFileSystemExecutor } from '../../utils/command.ts';
@@ -543,8 +541,12 @@
   return listed;
 }
 
-async function listAvailableDevices(executor: CommandExecutor): Promise<SetupDevice[]> {
-  const jsonPath = path.join(tmpdir(), `xcodebuildmcp-setup-devices-${Date.now()}.json`);
+async function listAvailableDevices(
+  executor: CommandExecutor,
+  fs: FileSystemExecutor,
+): Promise<SetupDevice[]> {
+  const tmpDir = await fs.mkdtemp(path.join(fs.tmpdir(), 'xcodebuildmcp-setup-devices-'));
+  const jsonPath = path.join(tmpDir, 'devices.json');
 
   try {
     const result = await executor(
@@ -564,7 +566,7 @@
   } catch {
     // Fall back to xctrace below.
   } finally {
-    await fs.unlink(jsonPath).catch(() => {});
+    await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
   }
 
   const fallbackResult = await executor(
@@ -595,6 +597,7 @@
 async function selectDevice(opts: {
   existingDeviceId?: string;
   executor: CommandExecutor;
+  fs: FileSystemExecutor;
   prompter: Prompter;
   isTTY: boolean;
   quietOutput: boolean;
@@ -604,7 +607,7 @@
     quietOutput: opts.quietOutput,
     startMessage: 'Loading devices...',
     stopMessage: 'Devices loaded.',
-    task: () => listAvailableDevices(opts.executor),
+    task: () => listAvailableDevices(opts.executor, opts.fs),
   });
 
   if (devices.length === 0) {
@@ -725,6 +728,7 @@
     ? await selectDevice({
         existingDeviceId: existing.deviceId,
         executor: deps.executor,
+        fs: deps.fs,
         prompter: deps.prompter,
         isTTY,
         quietOutput: deps.quietOutput,

diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts
--- a/src/utils/config-store.ts
+++ b/src/utils/config-store.ts
@@ -65,6 +65,7 @@
   fs?: FileSystemExecutor;
   overrides?: RuntimeConfigOverrides;
   fileConfig?: ProjectConfig;
+  env?: NodeJS.ProcessEnv;
   resolved: ResolvedRuntimeConfig;
 };
 
@@ -365,6 +366,7 @@
   storeState.resolved.sessionDefaults = resolveSessionDefaults({
     overrides: storeState.overrides,
     fileConfig: storeState.fileConfig,
+    env: storeState.env,
   });
   storeState.resolved.sessionDefaultsProfiles = resolveSessionDefaultsProfiles({
     overrides: storeState.overrides,
@@ -576,6 +578,7 @@
   storeState.cwd = opts.cwd;
   storeState.fs = opts.fs;
   storeState.overrides = opts.overrides;
+  storeState.env = opts.env;
 
   let fileConfig: ProjectConfig | undefined;
   let found = false;
@@ -676,5 +679,6 @@
   storeState.fs = undefined;
   storeState.overrides = undefined;
   storeState.fileConfig = undefined;
+  storeState.env = undefined;
   storeState.resolved = { ...DEFAULT_CONFIG };
 }

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Continue setup when simulator or device discovery returns no candidates instead of aborting. This keeps the explicit no-default path usable and prevents setup from failing on machines that are missing optional runtime targets.

Route setup device discovery temp-file handling through FileSystemExecutor and preserve injected env-backed session defaults after config-store refreshes so tests and runtime state stay consistent.
}

return 'Unknown';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated platform label function across two files

Low Severity

getDevicePlatformLabel in setup.ts is an exact duplicate of getPlatformLabel in src/mcp/tools/device/list_devices.ts. Both have identical logic mapping platform identifiers to human-readable labels. If the mapping ever needs updating (e.g., a new Apple platform), only one copy may get fixed, leading to inconsistencies.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree the duplication is real, but I’m treating it as refactor work rather than a correctness bug in this PR. The current mapping in setup.ts matches src/mcp/tools/device/list_devices.ts, and extracting a shared helper would be scope expansion for this review-fix pass, so I’m leaving this thread unresolved for a separate cleanup if we want it.

Return an empty device list when both devicectl and xctrace discovery fail so setup can continue with no default device instead of aborting. Add regression coverage for the double-failure discovery path.
Allow setup to continue when optional simulator or device discovery
fails instead of aborting the wizard.

Catch rejecting simulator and xctrace lookups, and treat injected
filesystem temp-path failures the same way so optional defaults can
still be skipped during setup.
Copy link
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Keep optional device setup resilient without skipping viable fallback
behavior.

When temp-path creation fails, still fall through to xctrace instead of
returning an empty device list immediately. Add regression coverage for
tmpdir failures, malformed device JSON, and simulator text-fallback
failures so optional defaults keep degrading to skip instead of aborting
setup.
@cameroncooke cameroncooke merged commit 2996e8d into main Mar 12, 2026
10 checks passed
@cameroncooke cameroncooke deleted the feat/env-var-session-defaults branch March 12, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

config.yaml is unreliable with MCP clients — env vars should be the primary configuration surface

1 participant