diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ec7e854..e69f50c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,27 @@ # Changelog ## [Unreleased] + +### Breaking +- By default when the `enabledWorkflows` configuration option or `XCODEBUILDMCP_ENABLED_WORKFLOWS` environment variable is not set or empty, XcodeBuildMCP will default to loading only the `simulator` workflow. This is a change in behaviour; previously it would load all workflows and therefore tools by default. + +This change reduces the number of tools loaded by default and requires the user to opt in to enable additional sets of tools based on their project or workflow requirements. + +The simulator workflow is the default because it is the most common use case based on opt-in analytics data. + +For more information see the [CONFIGURATION.md](docs/CONFIGURATION.md) documentation. + +- Tool names and descriptions have been made more concise to reduce token consumption. Tool argument names that are self-explanatory have had their descriptions removed entirely. + ### Added - Add Smithery support for packaging/distribution. - Add DAP-based debugger backend and simulator debugging toolset (attach, breakpoints, stack, variables, LLDB command). - Add session-status MCP resource with session identifiers. - Add UI automation guard that blocks UI tools when the debugger is paused. -- Add manage-workflows tool for live workflow selection updates. -- Add a bundled XcodeBuildMCP skill to improve MCP client tool discovery. +- Add `manage-workflows` tool to allow agents to change the workflows enable/disabling tools at runtime. This requires clients to support tools changed notifications. (opt-in only) +- Add XcodeBuildMCP skill to improve MCP client tool use/discovery, this needs to be installed see [README.md](README.md) for more information. +- Added support for `.xcodebuildmcp/config.yaml` files for runtime configuration, this is a more flexible and powerful way to configure XcodeBuildMCP than environment variables. +- Added support for session-aware defaults that are persisted between sessions in the config file. ### Changed - Migrate to Zod v4. @@ -15,7 +29,10 @@ - Auto-include workflow-discovery when workflow selection is configured. - Remove dynamic tool discovery (`discover_tools`) and `XCODEBUILDMCP_DYNAMIC_TOOLS`. Use `XCODEBUILDMCP_ENABLED_WORKFLOWS` to limit startup tool registration. - Add MCP tool annotations to all tools. - +- Route runtime configuration reads through the config store with layered precedence. +- Treat missing/empty `enabledWorkflows` as "load all workflows". +- Add config.yaml support for DAP/log capture tuning (`dapRequestTimeoutMs`, `dapLogEvents`, `launchJsonWaitMs`). + ### Fixed - Update UI automation guard guidance to point at `debug_continue` when paused. - Fix tool loading bugs in static tool registration. diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 00000000..c2942a3c --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,20 @@ +schemaVersion: 1 +enabledWorkflows: ["simulator", "ui-automation", "debugging"] +experimentalWorkflowDiscovery: false +disableSessionDefaults: false +incrementalBuildsEnabled: false +sessionDefaults: + projectPath: "./MyApp.xcodeproj" # xor workspacePath + workspacePath: "./MyApp.xcworkspace" # xor projectPath + scheme: "MyApp" + configuration: "Debug" + simulatorName: "iPhone 16" # xor simulatorId + simulatorId: "" # xor simulatorName + deviceId: "" + useLatestOS: true + arch: "arm64" + suppressWarnings: false + derivedDataPath: "./.derivedData" + preferXcodebuild: false + platform: "iOS" + bundleId: "com.example.myapp" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d77c479a..fc40c934 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,77 +1,28 @@ # Configuration -XcodeBuildMCP is configured through environment variables provided by your MCP client. Here is a single example showing how to add options to a typical MCP config: - -```json -"XcodeBuildMCP": { - "command": "npx", - "args": ["-y", "xcodebuildmcp@latest"], - "env": { - "XCODEBUILDMCP_ENABLED_WORKFLOWS": "simulator,device,project-discovery", - "INCREMENTAL_BUILDS_ENABLED": "false", - "XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS": "false", - "XCODEBUILDMCP_SENTRY_DISABLED": "false" - } -} -``` - -## Workflow selection - -By default, XcodeBuildMCP loads the `simulator` workflow at startup (plus `session-management`). If you want a smaller or different tool surface, set `XCODEBUILDMCP_ENABLED_WORKFLOWS` to a comma-separated list of workflow directory names. The `session-management` workflow is always auto-included since other tools depend on it. When `XCODEBUILDMCP_DEBUG=true`, the `doctor` workflow is also auto-included. - -**Available workflows:** -- `device` (14 tools) - iOS Device Development -- `simulator` (19 tools) - iOS Simulator Development -- `logging` (4 tools) - Log Capture & Management -- `macos` (11 tools) - macOS Development -- `project-discovery` (5 tools) - Project Discovery -- `project-scaffolding` (2 tools) - Project Scaffolding -- `utilities` (1 tool) - Project Utilities -- `session-management` (3 tools) - session-management -- `workflow-discovery` (1 tool) - Workflow Discovery -- `debugging` (8 tools) - Simulator Debugging -- `simulator-management` (8 tools) - Simulator Management -- `swift-package` (6 tools) - Swift Package Manager -- `doctor` (1 tool) - System Doctor -- `ui-testing` (11 tools) - UI Testing & Automation - -## Incremental build support - -XcodeBuildMCP includes experimental support for incremental builds. This feature is disabled by default and can be enabled by setting the `INCREMENTAL_BUILDS_ENABLED` environment variable to `true`. - -> [!IMPORTANT] -> Incremental builds are highly experimental and your mileage may vary. Please report issues to the [issue tracker](https://github.com/cameroncooke/XcodeBuildMCP/issues). - -## Experimental workflow discovery - -Set `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY=true` to auto-include the `workflow-discovery` workflow at startup. - -The workflow discovery tool lets agents and clients enable or disable workflows at runtime. This can reduce upfront context by loading only what is needed as the session evolves. Note: most clients do not yet support the MCP notifications required for an agent harness to re-query the tool list after changes. - -## Session-aware opt-out - -By default, XcodeBuildMCP uses a session-aware mode: the LLM (or client) sets shared defaults once (simulator, device, project/workspace, scheme, etc.), and all tools reuse them. This cuts context bloat not just in each call payload, but also in the tool schemas themselves. - -If you prefer the older, explicit style where each tool requires its own parameters, set `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS=true`. This restores the legacy schemas with per-call parameters while still honoring any session defaults you choose to set. +XcodeBuildMCP reads configuration from a project config file and environment variables. The config file is optional but provides deterministic, repo-scoped behavior for every session. -Leave this unset for the streamlined session-aware experience; enable it to force explicit parameters on each tool call. +## Precedence +For runtime config (non-session defaults), precedence is: +1. `.xcodebuildmcp/config.yaml` +2. Environment variables -## Project config (config.yaml) +## Config file (config.yaml) -You can provide deterministic session defaults for every AI coding session by creating a project config file at: +Create a config file at your workspace root. ``` /.xcodebuildmcp/config.yaml ``` -Notes: -- Put the file in your **workspace root** (where your Xcode project is located). -- Agents can persist changes by calling `session_set_defaults` with `"persist": true` (see below). - -### Schema +Example: ```yaml schemaVersion: 1 +enabledWorkflows: ["simulator", "ui-automation", "debugging"] +experimentalWorkflowDiscovery: false +disableSessionDefaults: false +incrementalBuildsEnabled: false sessionDefaults: projectPath: "./MyApp.xcodeproj" # xor workspacePath workspacePath: "./MyApp.xcworkspace" # xor projectPath @@ -89,8 +40,8 @@ sessionDefaults: bundleId: "com.example.myapp" ``` -Behavior: -- Relative paths in `projectPath`, `workspacePath`, and `derivedDataPath` resolve against the workspace root at load time. +Notes: +- `schemaVersion` is required and currently only supports `1`. - If both `projectPath` and `workspacePath` are set, **workspacePath wins**. - If both `simulatorId` and `simulatorName` are set, **simulatorId wins**. @@ -98,22 +49,127 @@ Behavior: By default, when the agent calls `session_set_defaults`, defaults are only stored in memory for that session. To persist them to the config file, ask the agent to set the `persist` flag to `true`. +## Workflow selection + +You can configure workflows in either: +- `enabledWorkflows` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_ENABLED_WORKFLOWS` (comma-separated) + +Notes: +- If `enabledWorkflows` is omitted, empty or not set, only the default `simulator` workflow is loaded. + +See [TOOLS.md](TOOLS.md) for a list of available workflows and tools. + +## Debug logging + +Enable debug logging with: +- `debug: true` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_DEBUG=true` + +This enables an extra doctor tool that agents can run to get MCP and system environment information useful for debugging issues with XcodeBuildMCP. + +## Incremental build support + +Enable incremental builds with either: +- `incrementalBuildsEnabled: true` in `config.yaml` (preferred), or +- via environment variable `INCREMENTAL_BUILDS_ENABLED=true` + +> [!IMPORTANT] +> Incremental builds are experimental and won't work for all projects. If you encounter issues, you can disable the option. The agent can also bypass incremental builds by passing a flag when calling build tools. + +## Experimental workflow discovery + +Enable via: +- `experimentalWorkflowDiscovery: true` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY=true` + +Enables experimental workflow discovery, this feature adds a `manage-workflows` tool that the agent can use to add/remove workflows at runtime. This requires clients to support tools changed notifications and therefore is an opt-in and experimental feature. + > [!IMPORTANT] -> The write is **patch-only**: only keys provided in that call are written (plus any removals needed for mutual exclusivity). +> At the time of writing, neither Cursor, Claude Code, nor Codex support tools changed notifications. + +## Session-aware opt-out + +Disable session-aware schemas with: +- `disableSessionDefaults: true` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS=true` + +Disables the session-aware defaults feature. This means that the agent will need to set the defaults for each tool call explicitly. This is not recommended and will use more tokens per call. It's recommended to only enable this if your specific requirements need the build, device and simulator settings change frequently in a single coding session, i.e. monorepos with multiple projects. + +## UI automation guard + +Control UI automation when a debugger is paused with: +- `uiDebuggerGuardMode: "error" | "warn" | "off"` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE=error|warn|off` + +This feature is used to block UI tools when the debugger is paused, this is to prevent the agent from executing UI tools that will fail or return incorrect results when the debugger is paused. + +Default is `error` when unset. ## Sentry telemetry opt-out -If you do not wish to send error logs to Sentry, set `XCODEBUILDMCP_SENTRY_DISABLED=true`. +Disable Sentry with: +- `XCODEBUILDMCP_SENTRY_DISABLED=true` + +By default we send error logs to Sentry, this can be disabled to prevent any error logs from being sent. + +See [PRIVACY.md](PRIVACY.md) for more information. ## AXe binary override -UI automation and simulator video capture require the AXe binary. By default, XcodeBuildMCP uses the bundled AXe when available, then falls back to `PATH`. To force a specific binary location, set `XCODEBUILDMCP_AXE_PATH` (preferred). `AXE_PATH` is also recognized for compatibility. +UI automation and simulator video capture require AXe. By default AXe is bundled with XcodeBuildMCP, but you can override the path to use a different version of AXe by setting these options. -Example: +Configure the binary path with: +- `axePath: "/opt/axe/bin/axe"` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_AXE_PATH=/opt/axe/bin/axe` -``` -XCODEBUILDMCP_AXE_PATH=/opt/axe/bin/axe -``` +For more information about AXe see the [AXe repository](https://github.com/cameroncooke/axe). + +## Template overrides + +The macOS and iOS scaffold tools pull templates from https://github.com/cameroncooke/XcodeBuildMCP-macOS-Template and https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template respectively. + +If you want to use your own source/fork for templates you can override the default locations and versions by setting these options. + +Set custom template locations and versions with: +- `iosTemplatePath` / `macosTemplatePath` in `config.yaml` (preferred), or +- via environment variable `XCODEBUILDMCP_IOS_TEMPLATE_PATH=/path/to/ios/templates` +- via environment variable `XCODEBUILDMCP_MACOS_TEMPLATE_PATH=/path/to/macos/templates` +- `iosTemplateVersion` / `macosTemplateVersion` in `config.yaml`, or +- `XCODEBUILD_MCP_IOS_TEMPLATE_VERSION=v1.2.3` +- `XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION=v1.2.3` + +These override the default template versions bundled in the package. + +## Debugger backend + +Select the debugger backend with: +- `debuggerBackend: "dap" | "lldb-cli"` in `config.yaml`, or +- `XCODEBUILDMCP_DEBUGGER_BACKEND=dap|lldb-cli` + +This overrides the debugger backend and defaults to `dap`. It's not generally recommended to change this. + +## DAP backend settings + +Tune the DAP backend with: +- `dapRequestTimeoutMs: 30000` in `config.yaml`, or +- `XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS=30000` + +This overrides the default request timeout of 30 seconds. + +Enable DAP event logging with: +- `dapLogEvents: true` in `config.yaml`, or +- `XCODEBUILDMCP_DAP_LOG_EVENTS=true` + +This enables logging of DAP events to the console. + +## Device log capture JSON wait + +Control how long we wait for devicectl JSON output with: +- `launchJsonWaitMs: 8000` in `config.yaml`, or +- `XBMCP_LAUNCH_JSON_WAIT_MS=8000` + +This overrides the default wait time of 8 seconds for devicectl JSON output. ## Related docs - Session defaults: [SESSION_DEFAULTS.md](SESSION_DEFAULTS.md) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index d9e3cea5..e523c6ae 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -34,6 +34,15 @@ Most MCP clients use JSON configuration. Add the following to your client config } ``` +## Project config (optional) +For deterministic session defaults and runtime configuration, add a config file at: + +```text +/.xcodebuildmcp/config.yaml +``` + +See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and examples. + ## Client-specific configuration ### OpenAI Codex CLI @@ -43,7 +52,7 @@ Codex uses TOML for MCP configuration. Add this to your Codex CLI config file: [mcp_servers.XcodeBuildMCP] command = "npx" args = ["-y", "xcodebuildmcp@latest"] -env = { "INCREMENTAL_BUILDS_ENABLED" = "false", "XCODEBUILDMCP_SENTRY_DISABLED" = "false" } +env = { "XCODEBUILDMCP_SENTRY_DISABLED" = "false" } ``` If you see tool calls timing out (for example, `timed out awaiting tools/call after 60s`), increase the timeout: @@ -61,7 +70,7 @@ https://github.com/openai/codex/blob/main/docs/config.md#connecting-to-mcp-serve claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest # Or with environment variables -claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest -e INCREMENTAL_BUILDS_ENABLED=false -e XCODEBUILDMCP_SENTRY_DISABLED=false +claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest -e XCODEBUILDMCP_SENTRY_DISABLED=false ``` Note: XcodeBuildMCP requests xcodebuild to skip macro validation to avoid Swift Macro build errors. diff --git a/docs/SESSION_DEFAULTS.md b/docs/SESSION_DEFAULTS.md index 5094ec6d..be4015d5 100644 --- a/docs/SESSION_DEFAULTS.md +++ b/docs/SESSION_DEFAULTS.md @@ -7,25 +7,21 @@ By default, XcodeBuildMCP uses a session-aware mode. The client sets shared defa - Tools reuse those defaults automatically. - Agent can call `session_show_defaults` to inspect current values. - Agent can call `session_clear_defaults` to clear values when switching contexts. -- Defaults can also be seeded from `.xcodebuildmcp/config.yaml` at server startup. +- Defaults can be seeded from `.xcodebuildmcp/config.yaml` at server startup. See the session-management tools in [TOOLS.md](TOOLS.md). ## Opting out -If you prefer explicit parameters on every tool call, set: +If you prefer explicit parameters on every tool call, set `disableSessionDefaults: true` in your `.xcodebuildmcp/config.yaml` file. -```json -"env": { - "XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS": "true" -} -``` +This restores the legacy schemas with per-call parameters while still honoring any defaults you choose to set. -This restores the legacy schemas with per-call parameters while still honoring any defaults you choose to set. Though this is not recommended, it can be useful in certain scenarios where you are working on monorepos or multiple projects at once. +See [CONFIGURATION.md](CONFIGURATION.md) for more information. ## Persisting defaults -Session defaults can be persisted between sessions by asking your agent to set the defaults with the `persist` flag set to `true`. This will save the defaults into `.xcodebuildmcp/config.yaml` at the root of your project's workspace. +Session defaults can be persisted between sessions by setting the `persist` flag to `true` on `session_set_defaults`. This writes to `.xcodebuildmcp/config.yaml` at the root of your workspace. -The persisted config is patch-only (only provided keys are written). +The persistence is patch-only: only keys provided in that call are written (plus any removals needed for mutual exclusivity). You can also manually create the config file to essentially seed the defaults at startup; see [CONFIGURATION.md](CONFIGURATION.md) for more information. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index cb4a11e7..5b77e67f 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -34,8 +34,8 @@ XcodeBuildMCP provides 72 tools organized into 14 workflow groups for comprehens - `start_device_log_cap` - Start device log capture. - `start_sim_log_cap` - Start sim log capture. -- `stop_device_log_cap` - Stop device log capture. -- `stop_sim_log_cap` - Stop sim log capture. +- `stop_device_log_cap` - Stop device app and return logs. +- `stop_sim_log_cap` - Stop sim app and return logs. ### macOS Development (`macos`) **Purpose**: Complete macOS development workflow for both .xcodeproj and .xcworkspace files. Build, test, deploy, and manage macOS applications. (6 tools) @@ -126,4 +126,4 @@ XcodeBuildMCP provides 72 tools organized into 14 workflow groups for comprehens --- -*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-27* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-28* diff --git a/docs/dev/PROJECT_CONFIG_PLAN.md b/docs/dev/PROJECT_CONFIG_PLAN.md index 6cff5157..405ed806 100644 --- a/docs/dev/PROJECT_CONFIG_PLAN.md +++ b/docs/dev/PROJECT_CONFIG_PLAN.md @@ -1,9 +1,11 @@ -# Project Config + Session Defaults Plan +# Project Config + Runtime Config Store Plan ## Goal -Add a project-level config file at `.xcodebuildmcp/config.yaml` that: +Add a project-level config file at `.xcodebuildmcp/config.yaml` and a global runtime config store that: 1. Seeds session defaults at server startup (no client call required). -2. Allows `session-set-defaults` to persist provided defaults back to that config when a flag is set. +2. Supports **all** configuration options in config.yaml (not just session defaults). +3. Uses environment variables as defaults for any unset config fields. +4. Exposes a single source of truth for configuration reads and persistence. Scope is limited to **cwd-only** resolution, **patch-only persistence** (provided keys only), and **warn+ignore** on invalid config. @@ -11,13 +13,31 @@ Scope is limited to **cwd-only** resolution, **patch-only persistence** (provide - Config location: **only** `process.cwd()/.xcodebuildmcp/config.yaml` (no find-up). - Persistence: **only** keys provided in the `session-set-defaults` call (plus necessary deletions for mutual exclusivity). - Invalid config: **warn and ignore**, continue startup. +- Config format: **flat** keys for everything except `sessionDefaults`. +- Workflow selection: `enabledWorkflows` is optional; when resolved, an empty array means "load all workflows". +- Config store pre-init: **safe defaults** (env + hardcoded) until initialized. ## Config Format -Proposed YAML: +Proposed YAML (flat except `sessionDefaults`): ```yaml schemaVersion: 1 +enabledWorkflows: ["simulator"] +debug: false +experimentalWorkflowDiscovery: false +disableSessionDefaults: false +uiDebuggerGuardMode: "warn" +incrementalBuildsEnabled: false +dapRequestTimeoutMs: 30000 +dapLogEvents: false +launchJsonWaitMs: 8000 +axePath: "/opt/axe/bin/axe" +iosTemplatePath: "/path/to/ios/templates" +iosTemplateVersion: "v1.2.3" +macosTemplatePath: "/path/to/macos/templates" +macosTemplateVersion: "v1.2.3" +debuggerBackend: "dap" sessionDefaults: projectPath: "./MyApp.xcodeproj" workspacePath: "./MyApp.xcworkspace" @@ -37,97 +57,81 @@ sessionDefaults: Notes: - `schemaVersion` supports future evolution. -- The config file is **not** exclusive to session defaults; future sections (e.g., `server`, `logging`, `discovery`) are expected. +- The config file is **not** exclusive to session defaults; more flat keys can be added as needed. - Relative paths resolve against the workspace root (cwd). ## Precedence (Operational) -We seed the in-memory session defaults from config at startup, so after boot it behaves like normal session defaults. -Operationally, the only precedence that matters during tool calls is: -1. Tool call args (existing behavior in `createSessionAwareTool`). -2. In-memory session defaults (initially seeded from config; can be changed by `session-set-defaults`). +Runtime config precedence for all non-session defaults: +1. Programmatic overrides (e.g., Smithery config) +2. Config file (`.xcodebuildmcp/config.yaml`) +3. Environment variables +4. Hardcoded defaults + +Session defaults precedence during tool calls remains: +1. Tool call args (existing behavior in `createSessionAwareTool`) +2. In-memory session defaults (seeded from config, mutable via `session-set-defaults`) ## Implementation Plan -### 1) New shared schema for session defaults -**File:** `src/utils/session-defaults-schema.ts` -- Define a Zod schema that mirrors `SessionDefaults`. -- Used by both config loading and `session-set-defaults` to avoid drift. +### 1) Unified config schema (flat + sessionDefaults) +**File:** `src/utils/runtime-config-schema.ts` (new) +- Define Zod schema for all supported config keys. +- Keep `sessionDefaults` shape from `session-defaults-schema.ts`. +- Allow unknown top-level keys via `.passthrough()`. -### 2) New project config loader/writer +### 2) Expand project config loader/writer **File:** `src/utils/project-config.ts` Responsibilities: - Resolve config path: `path.join(cwd, '.xcodebuildmcp', 'config.yaml')`. - Read YAML via `FileSystemExecutor`. -- Parse and validate with Zod. -- **Allow unknown top-level keys** (use `.passthrough()` in Zod) so non-session sections can exist without failing validation. -- Normalize mutual exclusivity: - - If both `projectPath` and `workspacePath` are set, keep `workspacePath`. - - If both `simulatorId` and `simulatorName` are set, keep `simulatorId`. +- Parse and validate with unified schema. +- Normalize mutual exclusivity in `sessionDefaults`. +- Normalize `enabledWorkflows` into a string[]; preserve "unset" at the config file level. - Resolve relative paths for `projectPath`, `workspacePath`, and `derivedDataPath`. - Persist changes when requested: - - Merge provided keys into `sessionDefaults`. - - Remove keys that were cleared due to exclusivity. - - Overwrite YAML file (comments not preserved). - -Suggested API: -```ts -export type LoadProjectConfigOptions = { - fs: FileSystemExecutor; - cwd: string; -}; - -export type LoadProjectConfigResult = - | { found: false } - | { found: true; path: string; config: ProjectConfig; notices: string[] }; - -export async function loadProjectConfig( - options: LoadProjectConfigOptions, -): Promise; - -export type PersistSessionDefaultsOptions = { - fs: FileSystemExecutor; - cwd: string; - patch: Partial; - deleteKeys?: (keyof SessionDefaults)[]; -}; - -export async function persistSessionDefaultsToProjectConfig( - options: PersistSessionDefaultsOptions, -): Promise<{ path: string }>; -``` - -### 3) Startup injection + - Deep-merge patch keys into existing config. + - Remove keys explicitly cleared (e.g., exclusivity deletions). + - Preserve unknown keys. + +### 3) Global config store (single source of truth) +**File:** `src/utils/config-store.ts` (new) +- Build resolved runtime config with precedence: + - overrides > config.yaml > env vars > defaults +- Provide `initConfigStore(...)` and `getConfig()` APIs. +- Provide a persistence helper for session defaults patches that updates config.yaml and the in-memory store. + +### 4) Startup initialization **File:** `src/server/bootstrap.ts` -- Accept `fileSystemExecutor` and `cwd` in `BootstrapOptions` (default to `getDefaultFileSystemExecutor()` and `process.cwd()`). -- Load project config at the top of `bootstrapServer()`. -- On success: `sessionStore.setDefaults(normalizedDefaults)`. -- On parse/validation error: log warning and continue. +- Initialize config store early with `cwd` + `fs`. +- Seed `sessionStore` from `config.sessionDefaults`. +- Use `config.enabledWorkflows`; empty array means "load all". -### 4) Persist flag in `session-set-defaults` -**File:** `src/mcp/tools/session-management/session_set_defaults.ts` -- Extend schema with `persist?: boolean`. -- Use `createTypedToolWithContext` to access `{ fs, cwd }`. -- Apply defaults to `sessionStore` as usual. -- If `persist === true`, call `persistSessionDefaultsToProjectConfig()` with: - - `patch`: only keys provided in the tool call (excluding `persist`). - - `deleteKeys`: keys removed due to exclusivity rules. -- Add a notice in response: `Persisted defaults to `. +### 5) Replace env reads with config store lookups +**Files:** `workflow-selection.ts`, `environment.ts`, `xcodemake.ts`, +`template-manager.ts`, `axe-helpers.ts`, `debugger-manager.ts` +- Keep existing behaviors, but route through config store. +- Env vars remain as defaults through the store. -### 5) Clear defaults key parity -**File:** `src/mcp/tools/session-management/session_clear_defaults.ts` -- Expand `keys` list to match full `SessionDefaults` surface. +### 6) Persist via config store +**File:** `src/mcp/tools/session-management/session_set_defaults.ts` +- Persist session defaults through config store API. +- Keep `deleteKeys` for mutual exclusivity. -### 6) Documentation updates -- Update `docs/SESSION_DEFAULTS.md` to mention config auto-load + `persist` flag. -- Update tool description in `src/mcp/tools/session-management/index.ts`. +### 7) Smithery overrides +**File:** `src/smithery.ts` +- Pass overrides into bootstrap/config store, so Smithery has highest precedence. -### 7) Dependency -- Add `yaml` package for parsing/serializing. +### 8) Documentation updates +- Update `docs/CONFIGURATION.md`, `docs/GETTING_STARTED.md`, `docs/SESSION_DEFAULTS.md`. ## Tests -- This change **must** be built TDD (red → green): write failing tests first, then implement code until tests pass. -- Add unit tests for `project-config` loader and persistence using `createMockFileSystemExecutor`. -- Update `session_set_defaults.test.ts` to cover `persist` path and mutual exclusivity deletions. +This change **must** be built TDD (red → green): write failing tests first, then implement code until tests pass. + +Red tests to add before implementation: +- `project-config` loader: flat keys + env fallback + enabledWorkflows normalization. +- `project-config` persistence: deep-merge patch + delete keys + preserve unknown keys. +- `config-store` resolution order: env vs config.yaml vs overrides. +- `session_set_defaults` persistence via config store. ## Risks / Notes - Overwriting YAML drops comments and custom formatting. diff --git a/example_projects/iOS/.xcodebuildmcp/config.yaml b/example_projects/iOS/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..25c7713b --- /dev/null +++ b/example_projects/iOS/.xcodebuildmcp/config.yaml @@ -0,0 +1,6 @@ +schemaVersion: 1 +sessionDefaults: + projectPath: ./MCPTest.xcodeproj + scheme: MCPTest + useLatestOS: true + platform: iOS Simulator diff --git a/src/index.ts b/src/index.ts index 17892008..c10f0039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,9 +24,6 @@ import { getDefaultDebuggerManager } from './utils/debugger/index.ts'; // Import version import { version } from './version.ts'; -// Import xcodemake utilities -import { isXcodemakeEnabled, isXcodemakeAvailable } from './utils/xcodemake.ts'; - // Import process for stdout configuration import process from 'node:process'; @@ -39,22 +36,6 @@ async function main(): Promise { try { initSentry(); - // Check if xcodemake is enabled and available - if (isXcodemakeEnabled()) { - log('info', 'xcodemake is enabled, checking if available...'); - const available = await isXcodemakeAvailable(); - if (available) { - log('info', 'xcodemake is available and will be used for builds'); - } else { - log( - 'warn', - 'xcodemake is enabled but could not be made available, falling back to xcodebuild', - ); - } - } else { - log('debug', 'xcodemake is disabled, using standard xcodebuild'); - } - // Create the server const server = createServer(); diff --git a/src/mcp/tools/doctor/doctor.ts b/src/mcp/tools/doctor/doctor.ts index 09b4b3d2..84d81c96 100644 --- a/src/mcp/tools/doctor/doctor.ts +++ b/src/mcp/tools/doctor/doctor.ts @@ -11,6 +11,7 @@ import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import { version } from '../../../utils/version/index.ts'; import { ToolResponse } from '../../../types/common.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; +import { getConfig } from '../../../utils/config-store.ts'; import { type DoctorDependencies, createDoctorDependencies } from './lib/doctor.deps.ts'; // Constants @@ -67,8 +68,8 @@ export async function runDoctor( const xcodemakeAvailable = await deps.features.isXcodemakeAvailable(); const makefileExists = deps.features.doesMakefileExist('./'); const lldbDapAvailable = await checkLldbDapAvailability(deps.commandExecutor); - const selectedDebuggerBackend = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND?.trim(); - const dapSelected = !selectedDebuggerBackend || selectedDebuggerBackend.toLowerCase() === 'dap'; + const selectedDebuggerBackend = getConfig().debuggerBackend; + const dapSelected = selectedDebuggerBackend === 'dap'; const doctorInfo = { serverVersion: version, @@ -95,7 +96,7 @@ export async function runDoctor( debugger: { dap: { available: lldbDapAvailable, - selected: selectedDebuggerBackend ?? '(default dap)', + selected: selectedDebuggerBackend, }, }, }, diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts index d110c041..9b721862 100644 --- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts @@ -2,7 +2,7 @@ * Tests for start_device_log_cap plugin * Following CLAUDE.md testing standards with pure dependency injection */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import { Readable } from 'stream'; import type { ChildProcess } from 'child_process'; @@ -14,6 +14,18 @@ import { import plugin, { start_device_log_capLogic } from '../start_device_log_cap.ts'; import { activeDeviceLogSessions } from '../../../../utils/log-capture/device-log-sessions.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../../../../utils/config-store.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} type Mutable = { -readonly [K in keyof T]: T[K]; @@ -35,20 +47,10 @@ describe('start_device_log_cap plugin', () => { let mkdirCalls: string[] = []; let writeFileCalls: Array<{ path: string; content: string }> = []; - const originalJsonWaitEnv = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; - - beforeEach(() => { + beforeEach(async () => { sessionStore.clear(); activeDeviceLogSessions.clear(); - process.env.XBMCP_LAUNCH_JSON_WAIT_MS = '25'; - }); - - afterEach(() => { - if (originalJsonWaitEnv === undefined) { - delete process.env.XBMCP_LAUNCH_JSON_WAIT_MS; - } else { - process.env.XBMCP_LAUNCH_JSON_WAIT_MS = originalJsonWaitEnv; - } + await initConfigStoreForTest({ launchJsonWaitMs: 25 }); }); describe('Plugin Structure', () => { diff --git a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts index 3b746075..60d4c229 100644 --- a/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_device_log_cap.test.ts @@ -32,7 +32,7 @@ describe('stop_device_log_cap plugin', () => { }); it('should have correct description', () => { - expect(plugin.description).toBe('Stop device log capture.'); + expect(plugin.description).toBe('Stop device app and return logs.'); }); it('should have correct schema structure', () => { diff --git a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts index f7962487..b3c37504 100644 --- a/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts +++ b/src/mcp/tools/logging/__tests__/stop_sim_log_cap.test.ts @@ -31,7 +31,7 @@ describe('stop_sim_log_cap plugin', () => { expect(stopSimLogCap).toHaveProperty('handler'); expect(stopSimLogCap.name).toBe('stop_sim_log_cap'); - expect(stopSimLogCap.description).toBe('Stop sim log capture.'); + expect(stopSimLogCap.description).toBe('Stop sim app and return logs.'); expect(typeof stopSimLogCap.handler).toBe('function'); expect(typeof stopSimLogCap.schema).toBe('object'); }); diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts index 694e9e40..b95e1eaa 100644 --- a/src/mcp/tools/logging/start_device_log_cap.ts +++ b/src/mcp/tools/logging/start_device_log_cap.ts @@ -24,6 +24,7 @@ import { type DeviceLogSession, } from '../../../utils/log-capture/device-log-sessions.ts'; import type { WriteStream } from 'fs'; +import { getConfig } from '../../../utils/config-store.ts'; /** * Log file retention policy for device logs: @@ -71,17 +72,11 @@ type DevicectlLaunchJson = { }; function getJsonResultWaitMs(): number { - const raw = process.env.XBMCP_LAUNCH_JSON_WAIT_MS; - if (raw === undefined) { + const configured = getConfig().launchJsonWaitMs; + if (!Number.isFinite(configured) || configured < 0) { return DEFAULT_JSON_RESULT_WAIT_MS; } - - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed < 0) { - return DEFAULT_JSON_RESULT_WAIT_MS; - } - - return parsed; + return configured; } function safeParseJson(text: string): DevicectlLaunchJson | null { diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts index efc1d59e..1b7d86a2 100644 --- a/src/mcp/tools/logging/stop_device_log_cap.ts +++ b/src/mcp/tools/logging/stop_device_log_cap.ts @@ -328,10 +328,10 @@ export async function stopDeviceLogCapture( export default { name: 'stop_device_log_cap', - description: 'Stop device log capture.', + description: 'Stop device app and return logs.', schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility annotations: { - title: 'Stop Device Log Capture', + title: 'Stop Device and Return Logs', destructiveHint: true, }, handler: createTypedTool( diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts index 6aa8614c..94538fd6 100644 --- a/src/mcp/tools/logging/stop_sim_log_cap.ts +++ b/src/mcp/tools/logging/stop_sim_log_cap.ts @@ -54,10 +54,10 @@ export async function stop_sim_log_capLogic( export default { name: 'stop_sim_log_cap', - description: 'Stop sim log capture.', + description: 'Stop sim app and return logs.', schema: stopSimLogCapSchema.shape, // MCP SDK compatibility annotations: { - title: 'Stop Simulator Log Capture', + title: 'Stop Simulator and Return Logs', destructiveHint: true, }, handler: createTypedTool( diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts index afabef34..b5f7216c 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_ios_project.test.ts @@ -14,13 +14,24 @@ import { createMockExecutor, createMockFileSystemExecutor, } from '../../../../test-utils/mock-executors.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../../../../utils/config-store.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} describe('scaffold_ios_project plugin', () => { let mockCommandExecutor: any; let mockFileSystemExecutor: any; - let originalEnv: string | undefined; - beforeEach(() => { + beforeEach(async () => { // Create mock executor using approved utility mockCommandExecutor = createMockExecutor({ success: true, @@ -51,19 +62,7 @@ describe('scaffold_ios_project plugin', () => { stat: async () => ({ isDirectory: () => true, mtimeMs: 0 }), }); - // Store original environment for cleanup - originalEnv = process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; - // Set local template path to avoid download and chdir issues - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; - }); - - afterEach(() => { - // Restore original environment - if (originalEnv !== undefined) { - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = originalEnv; - } else { - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; - } + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); describe('Export Field Validation (Literal)', () => { @@ -152,8 +151,7 @@ describe('scaffold_ios_project plugin', () => { describe('Command Generation Tests', () => { it('should generate correct curl command for iOS template download', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + await initConfigStoreForTest({ iosTemplatePath: '' }); // Track commands executed let capturedCommands: string[][] = []; @@ -191,13 +189,11 @@ describe('scaffold_ios_project plugin', () => { ), ]); - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); it.skip('should generate correct unzip command for iOS template extraction', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + await initConfigStoreForTest({ iosTemplatePath: '' }); // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ @@ -248,17 +244,11 @@ describe('scaffold_ios_project plugin', () => { expect(unzipCommand).toBeDefined(); expect(unzipCommand).toEqual(['unzip', '-q', expect.stringMatching(/template\.zip$/)]); - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); it('should generate correct commands when using custom template version', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; - - // Set custom template version - const originalVersion = process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; - process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = 'v2.0.0'; + await initConfigStoreForTest({ iosTemplatePath: '', iosTemplateVersion: 'v2.0.0' }); // Track commands executed let capturedCommands: string[][] = []; @@ -294,20 +284,11 @@ describe('scaffold_ios_project plugin', () => { 'https://github.com/cameroncooke/XcodeBuildMCP-iOS-Template/releases/download/v2.0.0/XcodeBuildMCP-iOS-Template-2.0.0.zip', ]); - // Restore original version - if (originalVersion) { - process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION = originalVersion; - } else { - delete process.env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION; - } - - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); it.skip('should generate correct commands with no command executor passed', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + await initConfigStoreForTest({ iosTemplatePath: '' }); // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ @@ -367,8 +348,7 @@ describe('scaffold_ios_project plugin', () => { expect(curlCommand[0]).toBe('curl'); expect(unzipCommand[0]).toBe('unzip'); - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); }); @@ -557,8 +537,7 @@ describe('scaffold_ios_project plugin', () => { }); it('should return error response for template download failure', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + await initConfigStoreForTest({ iosTemplatePath: '' }); // Mock command executor to fail for curl commands const failingMockCommandExecutor = createMockExecutor({ @@ -595,13 +574,11 @@ describe('scaffold_ios_project plugin', () => { isError: true, }); - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); it.skip('should return error response for template extraction failure', async () => { - // Temporarily disable local template to force download - delete process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + await initConfigStoreForTest({ iosTemplatePath: '' }); // Create a mock that returns false for local template paths to force download const downloadMockFileSystemExecutor = createMockFileSystemExecutor({ @@ -660,8 +637,7 @@ describe('scaffold_ios_project plugin', () => { isError: true, }); - // Restore environment variable - process.env.XCODEBUILDMCP_IOS_TEMPLATE_PATH = '/mock/template/path'; + await initConfigStoreForTest({ iosTemplatePath: '/mock/template/path' }); }); }); }); diff --git a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts index 49adea3a..c119934a 100644 --- a/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts +++ b/src/mcp/tools/project-scaffolding/__tests__/scaffold_macos_project.test.ts @@ -18,6 +18,18 @@ import { } from '../../../../test-utils/mock-executors.ts'; import plugin, { scaffold_macos_projectLogic } from '../scaffold_macos_project.ts'; import { TemplateManager } from '../../../../utils/template/index.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../../../../utils/config-store.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} // ONLY ALLOWED MOCKING: createMockFileSystemExecutor @@ -82,6 +94,8 @@ describe('scaffold_macos_project plugin', () => { // Replace the real TemplateManager with our stub for most tests (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; + + await initConfigStoreForTest(); }); describe('Export Field Validation (Literal)', () => { @@ -176,16 +190,12 @@ describe('scaffold_macos_project plugin', () => { }); }; - // Store original environment variable - const originalEnv = process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH; - // Mock local template path exists mockFileSystemExecutor.existsSync = (path: string) => { return path === '/local/template/path' || path === '/local/template/path/template'; }; - // Set environment variable for local template path - process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = '/local/template/path'; + await initConfigStoreForTest({ macosTemplatePath: '/local/template/path' }); // Restore original TemplateManager for command generation tests const { TemplateManager: OriginalTemplateManager } = await import( @@ -212,9 +222,6 @@ describe('scaffold_macos_project plugin', () => { expect.arrayContaining(['unzip', expect.anything(), expect.anything()]), ); - // Clean up environment variable - process.env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH = originalEnv; - // Restore stub after test (TemplateManager as any).getTemplatePath = templateManagerStub.getTemplatePath; (TemplateManager as any).cleanup = templateManagerStub.cleanup; 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 a006c718..0f0350fc 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 @@ -1,20 +1,22 @@ import { describe, it, expect, beforeEach } from 'vitest'; import path from 'node:path'; import { parse as parseYaml } from 'yaml'; +import { __resetConfigStoreForTests, initConfigStore } from '../../../../utils/config-store.ts'; import { sessionStore } from '../../../../utils/session-store.ts'; import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import plugin, { sessionSetDefaultsLogic } from '../session_set_defaults.ts'; describe('session-set-defaults tool', () => { beforeEach(() => { + __resetConfigStoreForTests(); sessionStore.clear(); }); const cwd = '/repo'; const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); - function createContext(overrides = {}) { - return { fs: createMockFileSystemExecutor(overrides), cwd }; + function createContext() { + return {}; } describe('Export Field Validation (Literal)', () => { @@ -162,7 +164,7 @@ describe('session-set-defaults tool', () => { ].join('\n'); const writes: { path: string; content: string }[] = []; - const context = createContext({ + const fs = createMockFileSystemExecutor({ existsSync: (targetPath: string) => targetPath === configPath, readFile: async (targetPath: string) => { if (targetPath !== configPath) { @@ -175,9 +177,11 @@ describe('session-set-defaults tool', () => { }, }); + await initConfigStore({ cwd, fs }); + const result = await sessionSetDefaultsLogic( { workspacePath: '/new/App.xcworkspace', simulatorId: 'SIM-1', persist: true }, - context, + createContext(), ); expect(result.content[0].text).toContain('Persisted defaults to'); @@ -194,13 +198,7 @@ describe('session-set-defaults tool', () => { }); it('should not persist when persist is true but no defaults were provided', async () => { - const context = createContext({ - writeFile: async () => { - throw new Error('writeFile should not be called'); - }, - }); - - const result = await sessionSetDefaultsLogic({ persist: true }, context); + const result = await sessionSetDefaultsLogic({ persist: true }, createContext()); expect(result.content[0].text).toContain('No defaults provided to persist'); }); diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index f629d712..f9b89b84 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -1,10 +1,7 @@ import * as z from 'zod'; -import process from 'node:process'; -import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; -import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; -import { getDefaultFileSystemExecutor } from '../../../utils/command.ts'; -import { persistSessionDefaultsToProjectConfig } from '../../../utils/project-config.ts'; +import { persistSessionDefaultsPatch } from '../../../utils/config-store.ts'; import { removeUndefined } from '../../../utils/remove-undefined.ts'; +import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; import { sessionDefaultsSchema } from '../../../utils/session-defaults-schema.ts'; import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import type { ToolResponse } from '../../../types/common.ts'; @@ -18,14 +15,11 @@ const schemaObj = sessionDefaultsSchema.extend({ type Params = z.infer; -type SessionSetDefaultsContext = { - fs: FileSystemExecutor; - cwd: string; -}; +type SessionSetDefaultsContext = Record; export async function sessionSetDefaultsLogic( params: Params, - context: SessionSetDefaultsContext, + neverContext: SessionSetDefaultsContext, ): Promise { const notices: string[] = []; const current = sessionStore.getAll(); @@ -112,9 +106,7 @@ export async function sessionSetDefaultsLogic( if (Object.keys(nextParams).length === 0 && toClear.size === 0) { notices.push('No defaults provided to persist.'); } else { - const { path } = await persistSessionDefaultsToProjectConfig({ - fs: context.fs, - cwd: context.cwd, + const { path } = await persistSessionDefaultsPatch({ patch: nextParams, deleteKeys: Array.from(toClear), }); @@ -143,8 +135,5 @@ export default { title: 'Set Session Defaults', destructiveHint: true, }, - handler: createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({ - fs: getDefaultFileSystemExecutor(), - cwd: process.cwd(), - })), + handler: createTypedToolWithContext(schemaObj, sessionSetDefaultsLogic, () => ({})), }; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 71d50fff..5c9db298 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -5,23 +5,17 @@ import { registerResources } from '../core/resources.ts'; import { getDefaultFileSystemExecutor } from '../utils/command.ts'; import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts'; import { log, setLogLevel, type LogLevel } from '../utils/logger.ts'; -import { loadProjectConfig } from '../utils/project-config.ts'; +import { getConfig, initConfigStore, type RuntimeConfigOverrides } from '../utils/config-store.ts'; import { sessionStore } from '../utils/session-store.ts'; import { registerWorkflows } from '../utils/tool-registry.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; + configOverrides?: RuntimeConfigOverrides; fileSystemExecutor?: FileSystemExecutor; cwd?: string; } -function parseEnabledWorkflows(value: string): string[] { - return value - .split(',') - .map((name) => name.trim().toLowerCase()) - .filter(Boolean); -} - export async function bootstrapServer( server: McpServer, options: BootstrapOptions = {}, @@ -36,45 +30,39 @@ export async function bootstrapServer( const cwd = options.cwd ?? process.cwd(); const fileSystemExecutor = options.fileSystemExecutor ?? getDefaultFileSystemExecutor(); - try { - const configResult = await loadProjectConfig({ fs: fileSystemExecutor, cwd }); - if (configResult.found) { - const defaults = configResult.config.sessionDefaults ?? {}; - if (Object.keys(defaults).length > 0) { - sessionStore.setDefaults(defaults); - } - for (const notice of configResult.notices) { - log('info', `[ProjectConfig] ${notice}`); - } - } else if ('error' in configResult) { - const errorMessage = - configResult.error instanceof Error - ? configResult.error.message - : String(configResult.error); - log( - 'warning', - `Failed to read or parse project config at ${configResult.path}. ${errorMessage}`, - ); - } - } catch (error) { - log('warning', `Failed to load project config from ${cwd}. ${error}`); + const hasLegacyEnabledWorkflows = Object.prototype.hasOwnProperty.call( + options, + 'enabledWorkflows', + ); + let overrides: RuntimeConfigOverrides | undefined; + if (options.configOverrides !== undefined) { + overrides = { ...options.configOverrides }; + } + if (hasLegacyEnabledWorkflows) { + overrides ??= {}; + overrides.enabledWorkflows = options.enabledWorkflows ?? []; } - const defaultEnabledWorkflows = ['simulator']; - - const enabledWorkflows = options.enabledWorkflows?.length - ? options.enabledWorkflows - : process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS - ? parseEnabledWorkflows(process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS) - : defaultEnabledWorkflows; + const configResult = await initConfigStore({ + cwd, + fs: fileSystemExecutor, + overrides, + }); + if (configResult.found) { + for (const notice of configResult.notices) { + log('info', `[ProjectConfig] ${notice}`); + } + } - if (enabledWorkflows.length > 0) { - log('info', `🚀 Initializing server with selected workflows: ${enabledWorkflows.join(', ')}`); - await registerWorkflows(enabledWorkflows); - } else { - log('info', '🚀 Initializing server with all tools...'); - await registerWorkflows([]); + const config = getConfig(); + const defaults = config.sessionDefaults ?? {}; + if (Object.keys(defaults).length > 0) { + sessionStore.setDefaults(defaults); } + const enabledWorkflows = config.enabledWorkflows; + log('info', `🚀 Initializing server...`); + await registerWorkflows(enabledWorkflows); + await registerResources(server); } diff --git a/src/smithery.ts b/src/smithery.ts index 9d8986a1..824e942c 100644 --- a/src/smithery.ts +++ b/src/smithery.ts @@ -5,48 +5,24 @@ import { createServer } from './server/server.ts'; import { log } from './utils/logger.ts'; import { initSentry } from './utils/sentry.ts'; -export const configSchema = z.object({ - incrementalBuildsEnabled: z - .boolean() - .default(false) - .describe('Enable incremental builds via xcodemake (true/false).'), - enabledWorkflows: z - .string() - .default('') - .describe('Comma-separated list of workflows to load at startup.'), - sentryDisabled: z.boolean().default(false).describe('Disable Sentry error reporting.'), - debug: z.boolean().default(false).describe('Enable debug logging.'), -}); +// Empty config schema - all configuration comes from config.yaml and env vars +export const configSchema = z.object({}); export type SmitheryConfig = z.infer; -function applyConfig(config: SmitheryConfig): string[] { - process.env.INCREMENTAL_BUILDS_ENABLED = config.incrementalBuildsEnabled ? '1' : '0'; - process.env.XCODEBUILDMCP_ENABLED_WORKFLOWS = config.enabledWorkflows; - process.env.XCODEBUILDMCP_SENTRY_DISABLED = config.sentryDisabled ? 'true' : 'false'; - process.env.XCODEBUILDMCP_DEBUG = config.debug ? 'true' : 'false'; - - return config.enabledWorkflows - .split(',') - .map((name) => name.trim()) - .filter(Boolean); -} - -export default function createSmitheryServer({ config }: { config: SmitheryConfig }): McpServer { - const workflowNames = applyConfig(config); - - initSentry(); - +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function createSmitheryServer(options: { config: SmitheryConfig }): McpServer { const server = createServer(); - const bootstrapPromise = bootstrapServer(server, { enabledWorkflows: workflowNames }).catch( - (error) => { - log( - 'error', - `Failed to bootstrap Smithery server: ${error instanceof Error ? error.message : String(error)}`, - ); - throw error; - }, - ); + const bootstrapPromise: Promise = (async (): Promise => { + initSentry(); + await bootstrapServer(server); + })().catch((error) => { + log( + 'error', + `Failed to bootstrap Smithery server: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + }); const handler: ProxyHandler = { get(target, prop, receiver): unknown { diff --git a/src/utils/__tests__/config-store.test.ts b/src/utils/__tests__/config-store.test.ts new file mode 100644 index 00000000..8f57a4f0 --- /dev/null +++ b/src/utils/__tests__/config-store.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; +import { __resetConfigStoreForTests, getConfig, initConfigStore } from '../config-store.ts'; + +const cwd = '/repo'; +const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + +describe('config-store', () => { + beforeEach(() => { + __resetConfigStoreForTests(); + }); + + function createFs(readFile?: string) { + return createMockFileSystemExecutor({ + existsSync: (targetPath) => targetPath === configPath && readFile != null, + readFile: async (targetPath) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + if (readFile == null) { + throw new Error('readFile called without fixture content'); + } + return readFile; + }, + }); + } + + it('uses defaults when config is missing and overrides are not provided', async () => { + await initConfigStore({ cwd, fs: createFs() }); + + const config = getConfig(); + expect(config.debug).toBe(false); + expect(config.incrementalBuildsEnabled).toBe(false); + expect(config.dapRequestTimeoutMs).toBe(30000); + expect(config.dapLogEvents).toBe(false); + expect(config.launchJsonWaitMs).toBe(8000); + }); + + it('parses env values when provided', async () => { + const env = { + XCODEBUILDMCP_DEBUG: 'true', + INCREMENTAL_BUILDS_ENABLED: '1', + XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '12345', + XCODEBUILDMCP_DAP_LOG_EVENTS: 'true', + XBMCP_LAUNCH_JSON_WAIT_MS: '9000', + XCODEBUILDMCP_ENABLED_WORKFLOWS: 'simulator,logging', + XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE: 'warn', + XCODEBUILDMCP_DEBUGGER_BACKEND: 'lldb', + }; + + await initConfigStore({ cwd, fs: createFs(), env }); + + const config = getConfig(); + expect(config.debug).toBe(true); + expect(config.incrementalBuildsEnabled).toBe(true); + expect(config.dapRequestTimeoutMs).toBe(12345); + expect(config.dapLogEvents).toBe(true); + expect(config.launchJsonWaitMs).toBe(9000); + expect(config.enabledWorkflows).toEqual(['simulator', 'logging']); + expect(config.uiDebuggerGuardMode).toBe('warn'); + expect(config.debuggerBackend).toBe('lldb-cli'); + }); + + it('prefers overrides over config file values and config over env', async () => { + const yaml = ['schemaVersion: 1', 'debug: false', 'dapRequestTimeoutMs: 4000', ''].join('\n'); + const env = { + XCODEBUILDMCP_DEBUG: 'true', + XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '999', + }; + + await initConfigStore({ + cwd, + fs: createFs(yaml), + overrides: { debug: true, dapRequestTimeoutMs: 12345 }, + env, + }); + + const config = getConfig(); + expect(config.debug).toBe(true); + expect(config.dapRequestTimeoutMs).toBe(12345); + }); + + it('resolves enabledWorkflows from overrides, config, then defaults', async () => { + const yamlWithoutWorkflows = ['schemaVersion: 1', 'debug: false', ''].join('\n'); + + await initConfigStore({ cwd, fs: createFs(yamlWithoutWorkflows) }); + + const config = getConfig(); + expect(config.enabledWorkflows).toEqual([]); + + const yamlWithExplicitEmpty = ['schemaVersion: 1', 'enabledWorkflows: []', ''].join('\n'); + + await initConfigStore({ cwd, fs: createFs(yamlWithExplicitEmpty) }); + + const explicitEmpty = getConfig(); + expect(explicitEmpty.enabledWorkflows).toEqual([]); + + await initConfigStore({ + cwd, + fs: createFs(yamlWithExplicitEmpty), + overrides: { enabledWorkflows: ['device'] }, + }); + + const updated = getConfig(); + expect(updated.enabledWorkflows).toEqual(['device']); + }); +}); diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts index a204a083..7f7893c8 100644 --- a/src/utils/__tests__/project-config.test.ts +++ b/src/utils/__tests__/project-config.test.ts @@ -55,6 +55,9 @@ describe('project-config', () => { it('should normalize mutual exclusivity and resolve relative paths', async () => { const yaml = [ 'schemaVersion: 1', + 'enabledWorkflows: simulator,device', + 'debug: true', + 'axePath: "./bin/axe"', 'sessionDefaults:', ' projectPath: "./App.xcodeproj"', ' workspacePath: "./App.xcworkspace"', @@ -70,6 +73,9 @@ describe('project-config', () => { if (!result.found) throw new Error('expected config to be found'); const defaults = result.config.sessionDefaults ?? {}; + expect(result.config.enabledWorkflows).toEqual(['simulator', 'device']); + expect(result.config.debug).toBe(true); + expect(result.config.axePath).toBe(path.join(cwd, 'bin', 'axe')); expect(defaults.workspacePath).toBe(path.join(cwd, 'App.xcworkspace')); expect(defaults.projectPath).toBeUndefined(); expect(defaults.simulatorId).toBe('SIM-1'); @@ -78,6 +84,25 @@ describe('project-config', () => { expect(result.notices.length).toBeGreaterThan(0); }); + it('should normalize debuggerBackend and resolve template paths', async () => { + const yaml = [ + 'schemaVersion: 1', + 'debuggerBackend: lldb', + 'iosTemplatePath: "./templates/ios"', + 'macosTemplatePath: "/opt/templates/macos"', + '', + ].join('\n'); + + const { fs } = createFsFixture({ exists: true, readFile: yaml }); + const result = await loadProjectConfig({ fs, cwd }); + + if (!result.found) throw new Error('expected config to be found'); + + expect(result.config.debuggerBackend).toBe('lldb-cli'); + expect(result.config.iosTemplatePath).toBe(path.join(cwd, 'templates', 'ios')); + expect(result.config.macosTemplatePath).toBe('/opt/templates/macos'); + }); + it('should return an error result when schemaVersion is unsupported', async () => { const yaml = ['schemaVersion: 2', 'sessionDefaults:', ' scheme: "App"', ''].join('\n'); const { fs } = createFsFixture({ exists: true, readFile: yaml }); @@ -106,6 +131,9 @@ describe('project-config', () => { it('should merge patches, delete exclusive keys, and preserve unknown sections', async () => { const yaml = [ 'schemaVersion: 1', + 'debug: true', + 'enabledWorkflows:', + ' - simulator', 'sessionDefaults:', ' scheme: "Old"', ' simulatorName: "OldSim"', @@ -130,11 +158,15 @@ describe('project-config', () => { const parsed = parseYaml(writes[0].content) as { schemaVersion: number; + debug?: boolean; + enabledWorkflows?: string[]; sessionDefaults?: Record; server?: { enabledWorkflows?: string[] }; }; expect(parsed.schemaVersion).toBe(1); + expect(parsed.debug).toBe(true); + expect(parsed.enabledWorkflows).toEqual(['simulator']); expect(parsed.sessionDefaults?.scheme).toBe('New'); expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); diff --git a/src/utils/__tests__/session-aware-tool-factory.test.ts b/src/utils/__tests__/session-aware-tool-factory.test.ts index 7543fe2d..3bbe5d74 100644 --- a/src/utils/__tests__/session-aware-tool-factory.test.ts +++ b/src/utils/__tests__/session-aware-tool-factory.test.ts @@ -2,11 +2,27 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as z from 'zod'; import { createSessionAwareTool } from '../typed-tool-factory.ts'; import { sessionStore } from '../session-store.ts'; -import { createMockExecutor } from '../../test-utils/mock-executors.ts'; +import { + createMockExecutor, + createMockFileSystemExecutor, +} from '../../test-utils/mock-executors.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../config-store.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} describe('createSessionAwareTool', () => { - beforeEach(() => { + beforeEach(async () => { sessionStore.clear(); + await initConfigStoreForTest({ disableSessionDefaults: false }); }); const internalSchema = z @@ -99,23 +115,14 @@ describe('createSessionAwareTool', () => { }); it('uses opt-out messaging when session defaults schema is disabled', async () => { - const original = process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; - process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = 'true'; - - try { - const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' }); - expect(result.isError).toBe(true); - const text = result.content[0].text; - expect(text).toContain('Missing required parameters'); - expect(text).toContain('scheme is required'); - expect(text).not.toContain('session defaults'); - } finally { - if (original === undefined) { - delete process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; - } else { - process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS = original; - } - } + await initConfigStoreForTest({ disableSessionDefaults: true }); + + const result = await handler({ projectPath: '/p.xcodeproj', simulatorId: 'SIM-1' }); + expect(result.isError).toBe(true); + const text = result.content[0].text; + expect(text).toContain('Missing required parameters'); + expect(text).toContain('scheme is required'); + expect(text).not.toContain('session defaults'); }); it('should surface Zod validation errors when invalid', async () => { diff --git a/src/utils/__tests__/workflow-selection.test.ts b/src/utils/__tests__/workflow-selection.test.ts index f6df09e8..64baa7b1 100644 --- a/src/utils/__tests__/workflow-selection.test.ts +++ b/src/utils/__tests__/workflow-selection.test.ts @@ -1,7 +1,20 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { z } from 'zod'; import { resolveSelectedWorkflows } from '../workflow-selection.ts'; import type { WorkflowGroup } from '../../core/plugin-types.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../config-store.ts'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} function makeWorkflow(name: string): WorkflowGroup { return { @@ -32,30 +45,11 @@ function makeWorkflowMap(names: string[]): Map { } describe('resolveSelectedWorkflows', () => { - let originalDebug: string | undefined; - let originalWorkflowDiscovery: string | undefined; - - beforeEach(() => { - originalDebug = process.env.XCODEBUILDMCP_DEBUG; - originalWorkflowDiscovery = process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY; - }); - - afterEach(() => { - if (typeof originalDebug === 'undefined') { - delete process.env.XCODEBUILDMCP_DEBUG; - } else { - process.env.XCODEBUILDMCP_DEBUG = originalDebug; - } - if (typeof originalWorkflowDiscovery === 'undefined') { - delete process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY; - } else { - process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY = originalWorkflowDiscovery; - } - }); - - it('adds doctor when debug is enabled and selection list is provided', () => { - process.env.XCODEBUILDMCP_DEBUG = 'true'; - process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY = 'true'; + it('adds doctor when debug is enabled and selection list is provided', async () => { + await initConfigStoreForTest({ + debug: true, + experimentalWorkflowDiscovery: true, + }); const workflows = makeWorkflowMap([ 'session-management', 'workflow-discovery', @@ -79,9 +73,11 @@ describe('resolveSelectedWorkflows', () => { ]); }); - it('does not add doctor when debug is disabled', () => { - process.env.XCODEBUILDMCP_DEBUG = 'false'; - process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY = 'true'; + it('does not add doctor when debug is disabled', async () => { + await initConfigStoreForTest({ + debug: false, + experimentalWorkflowDiscovery: true, + }); const workflows = makeWorkflowMap([ 'session-management', 'workflow-discovery', @@ -99,9 +95,11 @@ describe('resolveSelectedWorkflows', () => { ]); }); - it('returns all workflows when no selection list is provided', () => { - process.env.XCODEBUILDMCP_DEBUG = 'true'; - process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY = 'true'; + it('defaults to simulator workflow when no selection list is provided', async () => { + await initConfigStoreForTest({ + debug: true, + experimentalWorkflowDiscovery: true, + }); const workflows = makeWorkflowMap([ 'session-management', 'workflow-discovery', @@ -111,7 +109,12 @@ describe('resolveSelectedWorkflows', () => { const result = resolveSelectedWorkflows([], workflows); - expect(result.selectedNames).toBeNull(); + expect(result.selectedNames).toEqual([ + 'session-management', + 'workflow-discovery', + 'doctor', + 'simulator', + ]); expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ 'session-management', 'workflow-discovery', @@ -120,14 +123,16 @@ describe('resolveSelectedWorkflows', () => { ]); }); - it('excludes workflow-discovery when experimental flag is disabled', () => { - process.env.XCODEBUILDMCP_DEBUG = 'false'; - process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY = 'false'; + it('excludes workflow-discovery when experimental flag is disabled', async () => { + await initConfigStoreForTest({ + debug: false, + experimentalWorkflowDiscovery: false, + }); const workflows = makeWorkflowMap(['session-management', 'workflow-discovery', 'simulator']); const result = resolveSelectedWorkflows([], workflows); - expect(result.selectedNames).toBeNull(); + expect(result.selectedNames).toEqual(['session-management', 'simulator']); expect(result.selectedWorkflows.map((workflow) => workflow.directoryName)).toEqual([ 'session-management', 'simulator', diff --git a/src/utils/axe-helpers.ts b/src/utils/axe-helpers.ts index 122a7fda..38a2944d 100644 --- a/src/utils/axe-helpers.ts +++ b/src/utils/axe-helpers.ts @@ -11,8 +11,7 @@ import { createTextResponse } from './validation.ts'; import { ToolResponse } from '../types/common.ts'; import type { CommandExecutor } from './execution/index.ts'; import { getDefaultCommandExecutor } from './execution/index.ts'; - -const AXE_PATH_ENV_VARS = ['XCODEBUILDMCP_AXE_PATH', 'AXE_PATH'] as const; +import { getConfig } from './config-store.ts'; export type AxeBinarySource = 'env' | 'bundled' | 'path'; @@ -43,16 +42,11 @@ function isExecutable(path: string): boolean { } } -function resolveAxePathFromEnv(): string | null { - for (const envVar of AXE_PATH_ENV_VARS) { - const value = process.env[envVar]; - if (!value) continue; - const resolved = resolve(value); - if (isExecutable(resolved)) { - return resolved; - } - } - return null; +function resolveAxePathFromConfig(): string | null { + const value = getConfig().axePath; + if (!value) return null; + const resolved = resolve(value); + return isExecutable(resolved) ? resolved : null; } function resolveBundledAxePath(): string | null { @@ -87,9 +81,9 @@ function resolveAxePathFromPath(): string | null { } export function resolveAxeBinary(): AxeBinary | null { - const envPath = resolveAxePathFromEnv(); - if (envPath) { - return { path: envPath, source: 'env' }; + const configPath = resolveAxePathFromConfig(); + if (configPath) { + return { path: configPath, source: 'env' }; } const bundledPath = resolveBundledAxePath(); diff --git a/src/utils/config-store.ts b/src/utils/config-store.ts new file mode 100644 index 00000000..74aca1d3 --- /dev/null +++ b/src/utils/config-store.ts @@ -0,0 +1,459 @@ +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { SessionDefaults } from './session-store.ts'; +import { log } from './logger.ts'; +import { + loadProjectConfig, + persistSessionDefaultsToProjectConfig, + type ProjectConfig, +} from './project-config.ts'; +import type { DebuggerBackendKind } from './debugger/types.ts'; +import type { UiDebuggerGuardMode } from './runtime-config-types.ts'; + +export type RuntimeConfigOverrides = Partial<{ + enabledWorkflows: string[]; + debug: boolean; + experimentalWorkflowDiscovery: boolean; + disableSessionDefaults: boolean; + uiDebuggerGuardMode: UiDebuggerGuardMode; + incrementalBuildsEnabled: boolean; + dapRequestTimeoutMs: number; + dapLogEvents: boolean; + launchJsonWaitMs: number; + axePath: string; + iosTemplatePath: string; + iosTemplateVersion: string; + macosTemplatePath: string; + macosTemplateVersion: string; + debuggerBackend: DebuggerBackendKind; + sessionDefaults: Partial; +}>; + +export type ResolvedRuntimeConfig = { + enabledWorkflows: string[]; + debug: boolean; + experimentalWorkflowDiscovery: boolean; + disableSessionDefaults: boolean; + uiDebuggerGuardMode: UiDebuggerGuardMode; + incrementalBuildsEnabled: boolean; + dapRequestTimeoutMs: number; + dapLogEvents: boolean; + launchJsonWaitMs: number; + axePath?: string; + iosTemplatePath?: string; + iosTemplateVersion?: string; + macosTemplatePath?: string; + macosTemplateVersion?: string; + debuggerBackend: DebuggerBackendKind; + sessionDefaults?: Partial; +}; + +type ConfigStoreState = { + initialized: boolean; + cwd?: string; + fs?: FileSystemExecutor; + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; + resolved: ResolvedRuntimeConfig; +}; + +const DEFAULT_CONFIG: ResolvedRuntimeConfig = { + enabledWorkflows: [], + debug: false, + experimentalWorkflowDiscovery: false, + disableSessionDefaults: false, + uiDebuggerGuardMode: 'error', + incrementalBuildsEnabled: false, + dapRequestTimeoutMs: 30_000, + dapLogEvents: false, + launchJsonWaitMs: 8000, + debuggerBackend: 'dap', +}; + +const storeState: ConfigStoreState = { + initialized: false, + resolved: { ...DEFAULT_CONFIG }, +}; + +function hasOwnProperty( + obj: T | undefined, + key: K, +): obj is T & Record { + if (!obj) return false; + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function parseBoolean(value: string | undefined): boolean | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return undefined; +} + +function parsePositiveInt(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return Math.floor(parsed); +} + +function parseNonNegativeInt(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return Math.floor(parsed); +} + +function parseEnabledWorkflows(value: string | undefined): string[] | undefined { + if (value == null) return undefined; + const normalized = value + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + return normalized; +} + +function parseUiDebuggerGuardMode(value: string | undefined): UiDebuggerGuardMode | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (['off', '0', 'false', 'no'].includes(normalized)) return 'off'; + if (['warn', 'warning'].includes(normalized)) return 'warn'; + if (['error', '1', 'true', 'yes', 'on'].includes(normalized)) return 'error'; + return undefined; +} + +function parseDebuggerBackend(value: string | undefined): DebuggerBackendKind | undefined { + if (!value) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'lldb' || normalized === 'lldb-cli') return 'lldb-cli'; + if (normalized === 'dap') return 'dap'; + log('warning', `Unsupported debugger backend '${value}', falling back to defaults.`); + return undefined; +} + +function setIfDefined( + config: RuntimeConfigOverrides, + key: K, + value: RuntimeConfigOverrides[K] | undefined, +): void { + if (value !== undefined) { + config[key] = value; + } +} + +function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides { + const config: RuntimeConfigOverrides = {}; + + setIfDefined( + config, + 'enabledWorkflows', + parseEnabledWorkflows(env.XCODEBUILDMCP_ENABLED_WORKFLOWS), + ); + + setIfDefined(config, 'debug', parseBoolean(env.XCODEBUILDMCP_DEBUG)); + + setIfDefined( + config, + 'experimentalWorkflowDiscovery', + parseBoolean(env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY), + ); + + setIfDefined( + config, + 'disableSessionDefaults', + parseBoolean(env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS), + ); + + setIfDefined( + config, + 'uiDebuggerGuardMode', + parseUiDebuggerGuardMode(env.XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE), + ); + + setIfDefined(config, 'incrementalBuildsEnabled', parseBoolean(env.INCREMENTAL_BUILDS_ENABLED)); + + const axePath = env.XCODEBUILDMCP_AXE_PATH ?? env.AXE_PATH; + if (axePath) config.axePath = axePath; + + const iosTemplatePath = env.XCODEBUILDMCP_IOS_TEMPLATE_PATH; + if (iosTemplatePath) config.iosTemplatePath = iosTemplatePath; + + const macosTemplatePath = env.XCODEBUILDMCP_MACOS_TEMPLATE_PATH; + if (macosTemplatePath) config.macosTemplatePath = macosTemplatePath; + + const iosTemplateVersion = + env.XCODEBUILD_MCP_IOS_TEMPLATE_VERSION ?? env.XCODEBUILD_MCP_TEMPLATE_VERSION; + if (iosTemplateVersion) config.iosTemplateVersion = iosTemplateVersion; + + const macosTemplateVersion = + env.XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION ?? env.XCODEBUILD_MCP_TEMPLATE_VERSION; + if (macosTemplateVersion) config.macosTemplateVersion = macosTemplateVersion; + + setIfDefined(config, 'debuggerBackend', parseDebuggerBackend(env.XCODEBUILDMCP_DEBUGGER_BACKEND)); + + setIfDefined( + config, + 'dapRequestTimeoutMs', + parsePositiveInt(env.XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS), + ); + + setIfDefined(config, 'dapLogEvents', parseBoolean(env.XCODEBUILDMCP_DAP_LOG_EVENTS)); + + setIfDefined(config, 'launchJsonWaitMs', parseNonNegativeInt(env.XBMCP_LAUNCH_JSON_WAIT_MS)); + + return config; +} + +function resolveFromLayers(opts: { + key: keyof RuntimeConfigOverrides; + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; + envConfig: RuntimeConfigOverrides; + fallback: T; +}): T; +function resolveFromLayers(opts: { + key: keyof RuntimeConfigOverrides; + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; + envConfig: RuntimeConfigOverrides; + fallback?: undefined; +}): T | undefined; +function resolveFromLayers(opts: { + key: keyof RuntimeConfigOverrides; + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; + envConfig: RuntimeConfigOverrides; + fallback?: T; +}): T | undefined { + const { key, overrides, fileConfig, envConfig, fallback } = opts; + if (hasOwnProperty(overrides, key)) { + return overrides[key] as T | undefined; + } + if (hasOwnProperty(fileConfig, key)) { + return fileConfig[key] as T | undefined; + } + if (hasOwnProperty(envConfig, key)) { + return envConfig[key] as T | undefined; + } + return fallback; +} + +function resolveSessionDefaults(opts: { + overrides?: RuntimeConfigOverrides; + fileConfig?: ProjectConfig; +}): Partial | undefined { + const overrideDefaults = opts.overrides?.sessionDefaults; + const fileDefaults = opts.fileConfig?.sessionDefaults; + if (!overrideDefaults && !fileDefaults) return undefined; + return { ...(fileDefaults ?? {}), ...(overrideDefaults ?? {}) }; +} + +function resolveConfig(opts: { + fileConfig?: ProjectConfig; + overrides?: RuntimeConfigOverrides; + env?: NodeJS.ProcessEnv; +}): ResolvedRuntimeConfig { + const envConfig = readEnvConfig(opts.env ?? process.env); + + return { + enabledWorkflows: resolveFromLayers({ + key: 'enabledWorkflows', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.enabledWorkflows, + }), + debug: resolveFromLayers({ + key: 'debug', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.debug, + }), + experimentalWorkflowDiscovery: resolveFromLayers({ + key: 'experimentalWorkflowDiscovery', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.experimentalWorkflowDiscovery, + }), + disableSessionDefaults: resolveFromLayers({ + key: 'disableSessionDefaults', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.disableSessionDefaults, + }), + uiDebuggerGuardMode: resolveFromLayers({ + key: 'uiDebuggerGuardMode', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.uiDebuggerGuardMode, + }), + incrementalBuildsEnabled: resolveFromLayers({ + key: 'incrementalBuildsEnabled', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.incrementalBuildsEnabled, + }), + dapRequestTimeoutMs: resolveFromLayers({ + key: 'dapRequestTimeoutMs', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.dapRequestTimeoutMs, + }), + dapLogEvents: resolveFromLayers({ + key: 'dapLogEvents', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.dapLogEvents, + }), + launchJsonWaitMs: resolveFromLayers({ + key: 'launchJsonWaitMs', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.launchJsonWaitMs, + }), + axePath: resolveFromLayers({ + key: 'axePath', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), + iosTemplatePath: resolveFromLayers({ + key: 'iosTemplatePath', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), + iosTemplateVersion: resolveFromLayers({ + key: 'iosTemplateVersion', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), + macosTemplatePath: resolveFromLayers({ + key: 'macosTemplatePath', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), + macosTemplateVersion: resolveFromLayers({ + key: 'macosTemplateVersion', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + }), + debuggerBackend: resolveFromLayers({ + key: 'debuggerBackend', + overrides: opts.overrides, + fileConfig: opts.fileConfig, + envConfig, + fallback: DEFAULT_CONFIG.debuggerBackend, + }), + sessionDefaults: resolveSessionDefaults({ + overrides: opts.overrides, + fileConfig: opts.fileConfig, + }), + }; +} + +export async function initConfigStore(opts: { + cwd: string; + fs: FileSystemExecutor; + overrides?: RuntimeConfigOverrides; + env?: NodeJS.ProcessEnv; +}): Promise<{ found: boolean; path?: string; notices: string[] }> { + storeState.cwd = opts.cwd; + storeState.fs = opts.fs; + storeState.overrides = opts.overrides; + + let fileConfig: ProjectConfig | undefined; + let found = false; + let path: string | undefined; + let notices: string[] = []; + + try { + const result = await loadProjectConfig({ fs: opts.fs, cwd: opts.cwd }); + if (result.found) { + fileConfig = result.config; + found = true; + path = result.path; + notices = result.notices; + } else if ('error' in result) { + const errorMessage = + result.error instanceof Error ? result.error.message : String(result.error); + log('warning', `Failed to read or parse project config at ${result.path}. ${errorMessage}`); + } + } catch (error) { + log('warning', `Failed to load project config from ${opts.cwd}. ${error}`); + } + + storeState.fileConfig = fileConfig; + storeState.resolved = resolveConfig({ + fileConfig, + overrides: opts.overrides, + env: opts.env, + }); + storeState.initialized = true; + return { found, path, notices }; +} + +export function getConfig(): ResolvedRuntimeConfig { + if (!storeState.initialized) { + return resolveConfig({}); + } + + return storeState.resolved; +} + +export async function persistSessionDefaultsPatch(opts: { + patch: Partial; + deleteKeys?: (keyof SessionDefaults)[]; +}): Promise<{ path: string }> { + if (!storeState.initialized || !storeState.fs || !storeState.cwd) { + throw new Error('Config store has not been initialized.'); + } + + const result = await persistSessionDefaultsToProjectConfig({ + fs: storeState.fs, + cwd: storeState.cwd, + patch: opts.patch, + deleteKeys: opts.deleteKeys, + }); + + const nextSessionDefaults: Partial = { + ...(storeState.fileConfig?.sessionDefaults ?? {}), + ...opts.patch, + }; + + for (const key of opts.deleteKeys ?? []) { + delete nextSessionDefaults[key]; + } + + storeState.fileConfig = { + ...(storeState.fileConfig ?? { schemaVersion: 1 }), + sessionDefaults: nextSessionDefaults, + }; + + storeState.resolved = resolveConfig({ + fileConfig: storeState.fileConfig, + overrides: storeState.overrides, + }); + + return result; +} + +export function __resetConfigStoreForTests(): void { + storeState.initialized = false; + storeState.cwd = undefined; + storeState.fs = undefined; + storeState.overrides = undefined; + storeState.fileConfig = undefined; + storeState.resolved = { ...DEFAULT_CONFIG }; +} diff --git a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts index f6425290..b809dd42 100644 --- a/src/utils/debugger/__tests__/debugger-manager-dap.test.ts +++ b/src/utils/debugger/__tests__/debugger-manager-dap.test.ts @@ -1,8 +1,21 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import type { BreakpointInfo, BreakpointSpec } from '../types.ts'; import type { DebuggerBackend } from '../backends/DebuggerBackend.ts'; import { DebuggerManager } from '../debugger-manager.ts'; +import { + __resetConfigStoreForTests, + initConfigStore, + type RuntimeConfigOverrides, +} from '../../config-store.ts'; +import { createMockFileSystemExecutor } from '../../../test-utils/mock-executors.ts'; + +const cwd = '/repo'; + +async function initConfigStoreForTest(overrides?: RuntimeConfigOverrides): Promise { + __resetConfigStoreForTests(); + await initConfigStore({ cwd, fs: createMockFileSystemExecutor(), overrides }); +} function createBackend(overrides: Partial = {}): DebuggerBackend { const base: DebuggerBackend = { @@ -27,20 +40,8 @@ function createBackend(overrides: Partial = {}): DebuggerBacken } describe('DebuggerManager DAP selection', () => { - const envKey = 'XCODEBUILDMCP_DEBUGGER_BACKEND'; - let prevEnv: string | undefined; - - afterEach(() => { - if (prevEnv === undefined) { - delete process.env[envKey]; - } else { - process.env[envKey] = prevEnv; - } - }); - - it('selects dap backend when env is set', async () => { - prevEnv = process.env[envKey]; - process.env[envKey] = 'dap'; + it('selects dap backend when config override is set', async () => { + await initConfigStoreForTest({ debuggerBackend: 'dap' }); let selected: string | null = null; const backend = createBackend({ kind: 'dap' }); diff --git a/src/utils/debugger/backends/dap-backend.ts b/src/utils/debugger/backends/dap-backend.ts index 74accc2b..ae1566e8 100644 --- a/src/utils/debugger/backends/dap-backend.ts +++ b/src/utils/debugger/backends/dap-backend.ts @@ -3,6 +3,7 @@ import type { BreakpointInfo, BreakpointSpec, DebugExecutionState } from '../typ import type { CommandExecutor, InteractiveSpawner } from '../../execution/index.ts'; import { getDefaultCommandExecutor, getDefaultInteractiveSpawner } from '../../execution/index.ts'; import { log } from '../../logging/index.ts'; +import { getConfig } from '../../config-store.ts'; import type { DapEvent, EvaluateResponseBody, @@ -16,7 +17,6 @@ import type { import { DapTransport } from '../dap/transport.ts'; import { resolveLldbDapCommand } from '../dap/adapter-discovery.ts'; -const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const LOG_PREFIX = '[DAP Backend]'; type FileLineBreakpointRecord = { line: number; condition?: string; id?: number }; @@ -579,33 +579,20 @@ function formatVariable(variable: { name: string; value: string; type?: string } return `${variable.name}${typeSuffix} = ${variable.value}`; } -function parseRequestTimeoutMs(): number { - const raw = process.env.XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS; - if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS; - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_REQUEST_TIMEOUT_MS; - } - return parsed; -} - -function parseLogEvents(): boolean { - return process.env.XCODEBUILDMCP_DAP_LOG_EVENTS === 'true'; -} - export async function createDapBackend(opts?: { executor?: CommandExecutor; spawner?: InteractiveSpawner; requestTimeoutMs?: number; }): Promise { + const config = getConfig(); const executor = opts?.executor ?? getDefaultCommandExecutor(); const spawner = opts?.spawner ?? getDefaultInteractiveSpawner(); - const requestTimeoutMs = opts?.requestTimeoutMs ?? parseRequestTimeoutMs(); + const requestTimeoutMs = opts?.requestTimeoutMs ?? config.dapRequestTimeoutMs; const backend = new DapBackend({ executor, spawner, requestTimeoutMs, - logEvents: parseLogEvents(), + logEvents: config.dapLogEvents, }); return backend; } diff --git a/src/utils/debugger/debugger-manager.ts b/src/utils/debugger/debugger-manager.ts index b9df0451..e1f681f4 100644 --- a/src/utils/debugger/debugger-manager.ts +++ b/src/utils/debugger/debugger-manager.ts @@ -9,6 +9,7 @@ import type { DebugSessionInfo, DebuggerBackendKind, } from './types.ts'; +import { getConfig } from '../config-store.ts'; export type DebuggerBackendFactory = (kind: DebuggerBackendKind) => Promise; @@ -205,12 +206,7 @@ export class DebuggerManager { function resolveBackendKind(explicit?: DebuggerBackendKind): DebuggerBackendKind { if (explicit) return explicit; - const envValue = process.env.XCODEBUILDMCP_DEBUGGER_BACKEND; - if (!envValue) return 'dap'; - const normalized = envValue.trim().toLowerCase(); - if (normalized === 'lldb-cli' || normalized === 'lldb') return 'lldb-cli'; - if (normalized === 'dap') return 'dap'; - throw new Error(`Unsupported debugger backend: ${envValue}`); + return getConfig().debuggerBackend; } const defaultBackendFactory: DebuggerBackendFactory = async (kind) => { diff --git a/src/utils/environment.ts b/src/utils/environment.ts index 8f43ac8f..f85021f0 100644 --- a/src/utils/environment.ts +++ b/src/utils/environment.ts @@ -7,6 +7,8 @@ import { execSync } from 'child_process'; import { log } from './logger.ts'; +import { getConfig } from './config-store.ts'; +import type { UiDebuggerGuardMode } from './runtime-config-types.ts'; /** * Interface for environment detection abstraction @@ -71,24 +73,12 @@ export function getDefaultEnvironmentDetector(): EnvironmentDetector { * Global opt-out for session defaults in MCP tool schemas. * When enabled, tools re-expose all parameters instead of hiding session-managed fields. */ -export function isSessionDefaultsSchemaOptOutEnabled(): boolean { - const raw = process.env.XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS; - if (!raw) return false; - - const normalized = raw.trim().toLowerCase(); - return ['1', 'true', 'yes', 'on'].includes(normalized); +export function isSessionDefaultsOptOutEnabled(): boolean { + return getConfig().disableSessionDefaults; } -export type UiDebuggerGuardMode = 'error' | 'warn' | 'off'; - export function getUiDebuggerGuardMode(): UiDebuggerGuardMode { - const raw = process.env.XCODEBUILDMCP_UI_DEBUGGER_GUARD_MODE; - if (!raw) return 'error'; - - const normalized = raw.trim().toLowerCase(); - if (['off', '0', 'false', 'no'].includes(normalized)) return 'off'; - if (['warn', 'warning'].includes(normalized)) return 'warn'; - return 'error'; + return getConfig().uiDebuggerGuardMode; } /** diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 05635fb2..66cec50e 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -21,8 +21,13 @@ import { createRequire } from 'node:module'; import { resolve } from 'node:path'; // Note: Removed "import * as Sentry from '@sentry/node'" to prevent native module loading at import time -const SENTRY_ENABLED = - process.env.SENTRY_DISABLED !== 'true' && process.env.XCODEBUILDMCP_SENTRY_DISABLED !== 'true'; +function isSentryDisabledFromEnv(): boolean { + return ( + process.env.SENTRY_DISABLED === 'true' || process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true' + ); +} + +const sentryEnabled = !isSentryDisabledFromEnv(); // Log levels in order of severity (lower number = more severe) const LOG_LEVELS = { @@ -64,7 +69,7 @@ const require = createRequire( let cachedSentry: SentryModule | null = null; function loadSentrySync(): SentryModule | null { - if (!SENTRY_ENABLED || isTestEnv()) return null; + if (!sentryEnabled || isTestEnv()) return null; if (cachedSentry) return cachedSentry; try { cachedSentry = require('@sentry/node') as SentryModule; @@ -85,14 +90,6 @@ function withSentry(cb: (s: SentryModule) => void): void { } } -if (!SENTRY_ENABLED) { - if (process.env.SENTRY_DISABLED === 'true') { - log('info', 'Sentry disabled due to SENTRY_DISABLED environment variable'); - } else if (process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true') { - log('info', 'Sentry disabled due to XCODEBUILDMCP_SENTRY_DISABLED environment variable'); - } -} - /** * Set the minimum log level for client-requested filtering * @param level The minimum log level to output @@ -153,7 +150,7 @@ export function log(level: string, message: string, context?: LogContext): void // Default: error level goes to Sentry // But respect explicit override from context - const captureToSentry = SENTRY_ENABLED && (context?.sentry ?? level === 'error'); + const captureToSentry = sentryEnabled && (context?.sentry ?? level === 'error'); if (captureToSentry) { withSentry((s) => s.captureMessage(logMessage)); diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts index 7c3c0f76..dec09e31 100644 --- a/src/utils/project-config.ts +++ b/src/utils/project-config.ts @@ -1,27 +1,19 @@ import path from 'node:path'; -import * as z from 'zod'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { FileSystemExecutor } from './FileSystemExecutor.ts'; import type { SessionDefaults } from './session-store.ts'; import { log } from './logger.ts'; -import { sessionDefaultsSchema } from './session-defaults-schema.ts'; import { removeUndefined } from './remove-undefined.ts'; +import { runtimeConfigFileSchema, type RuntimeConfigFile } from './runtime-config-schema.ts'; const CONFIG_DIR = '.xcodebuildmcp'; const CONFIG_FILE = 'config.yaml'; -const projectConfigSchema = z - .object({ - schemaVersion: z.literal(1).optional().default(1), - sessionDefaults: sessionDefaultsSchema.optional(), - }) - .passthrough(); - -type ProjectConfigSchema = z.infer; - -export type ProjectConfig = { +export type ProjectConfig = RuntimeConfigFile & { schemaVersion: 1; sessionDefaults?: Partial; + enabledWorkflows?: string[]; + debuggerBackend?: 'dap' | 'lldb-cli'; [key: string]: unknown; }; @@ -99,12 +91,66 @@ function resolveRelativeSessionPaths( return resolved; } -function parseProjectConfig(rawText: string): ProjectConfigSchema { +function normalizeEnabledWorkflows(value: unknown): string[] { + if (value == null) return []; + if (Array.isArray(value)) { + const normalized = value + .filter((name): name is string => typeof name === 'string') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + return normalized; + } + if (typeof value === 'string') { + const normalized = value + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean); + return normalized; + } + return []; +} + +function resolveRelativeTopLevelPaths(config: ProjectConfig, cwd: string): ProjectConfig { + const resolved: ProjectConfig = { ...config }; + const pathKeys = ['axePath', 'iosTemplatePath', 'macosTemplatePath'] as const; + + for (const key of pathKeys) { + const value = resolved[key]; + if (typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)) { + resolved[key] = path.resolve(cwd, value); + } + } + + return resolved; +} + +function normalizeDebuggerBackend(config: RuntimeConfigFile): ProjectConfig { + if (config.debuggerBackend === 'lldb') { + const normalized: RuntimeConfigFile = { ...config, debuggerBackend: 'lldb-cli' }; + return toProjectConfig(normalized); + } + return toProjectConfig(config); +} + +function normalizeConfigForPersistence(config: RuntimeConfigFile): ProjectConfig { + const base = normalizeDebuggerBackend(config); + if (config.enabledWorkflows === undefined) { + return base; + } + const normalizedWorkflows = normalizeEnabledWorkflows(config.enabledWorkflows); + return { ...base, enabledWorkflows: normalizedWorkflows }; +} + +function toProjectConfig(config: RuntimeConfigFile): ProjectConfig { + return config as ProjectConfig; +} + +function parseProjectConfig(rawText: string): RuntimeConfigFile { const parsed: unknown = parseYaml(rawText); if (!isPlainObject(parsed)) { throw new Error('Project config must be an object'); } - return projectConfigSchema.parse(parsed); + return runtimeConfigFileSchema.parse(parsed) as RuntimeConfigFile; } export async function loadProjectConfig( @@ -116,22 +162,26 @@ export async function loadProjectConfig( return { found: false }; } - let parsed: ProjectConfigSchema; try { const rawText = await options.fs.readFile(configPath, 'utf8'); - parsed = parseProjectConfig(rawText); + const parsed = parseProjectConfig(rawText); + const notices: string[] = []; + + let config = normalizeDebuggerBackend(parsed); - if (!parsed.sessionDefaults) { - return { found: true, path: configPath, config: parsed, notices: [] }; + if (parsed.enabledWorkflows !== undefined) { + const normalizedWorkflows = normalizeEnabledWorkflows(parsed.enabledWorkflows); + config = { ...config, enabledWorkflows: normalizedWorkflows }; } - const { normalized, notices } = normalizeMutualExclusivity(parsed.sessionDefaults); - const resolved = resolveRelativeSessionPaths(normalized, options.cwd); + if (config.sessionDefaults) { + const normalized = normalizeMutualExclusivity(config.sessionDefaults); + notices.push(...normalized.notices); + const resolved = resolveRelativeSessionPaths(normalized.normalized, options.cwd); + config = { ...config, sessionDefaults: resolved }; + } - const config: ProjectConfig = { - ...parsed, - sessionDefaults: resolved, - }; + config = resolveRelativeTopLevelPaths(config, options.cwd); return { found: true, path: configPath, config, notices }; } catch (error) { @@ -153,7 +203,7 @@ export async function persistSessionDefaultsToProjectConfig( try { const rawText = await options.fs.readFile(configPath, 'utf8'); const parsed = parseProjectConfig(rawText); - baseConfig = { ...parsed, schemaVersion: 1 }; + baseConfig = { ...normalizeConfigForPersistence(parsed), schemaVersion: 1 }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log( diff --git a/src/utils/runtime-config-schema.ts b/src/utils/runtime-config-schema.ts new file mode 100644 index 00000000..6158b4a6 --- /dev/null +++ b/src/utils/runtime-config-schema.ts @@ -0,0 +1,28 @@ +import * as z from 'zod'; +import { sessionDefaultsSchema } from './session-defaults-schema.ts'; + +export const runtimeConfigFileSchema = z + .object({ + schemaVersion: z.literal(1).optional().default(1), + enabledWorkflows: z.union([z.array(z.string()), z.string()]).optional(), + debug: z.boolean().optional(), + experimentalWorkflowDiscovery: z.boolean().optional(), + disableSessionDefaults: z.boolean().optional(), + uiDebuggerGuardMode: z.enum(['error', 'warn', 'off']).optional(), + incrementalBuildsEnabled: z.boolean().optional(), + dapRequestTimeoutMs: z.number().int().positive().optional(), + dapLogEvents: z.boolean().optional(), + launchJsonWaitMs: z.number().int().nonnegative().optional(), + axePath: z.string().optional(), + iosTemplatePath: z.string().optional(), + iosTemplateVersion: z.string().optional(), + macosTemplatePath: z.string().optional(), + macosTemplateVersion: z.string().optional(), + debuggerBackend: z.enum(['dap', 'lldb-cli', 'lldb']).optional(), + sessionDefaults: sessionDefaultsSchema.optional(), + }) + .passthrough(); + +export type RuntimeConfigFile = z.infer & { + [key: string]: unknown; +}; diff --git a/src/utils/runtime-config-types.ts b/src/utils/runtime-config-types.ts new file mode 100644 index 00000000..3d953113 --- /dev/null +++ b/src/utils/runtime-config-types.ts @@ -0,0 +1 @@ +export type UiDebuggerGuardMode = 'error' | 'warn' | 'off'; diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts index 8ecba4f2..e9888e7d 100644 --- a/src/utils/sentry.ts +++ b/src/utils/sentry.ts @@ -85,7 +85,7 @@ let initialized = false; function isSentryDisabled(): boolean { return ( - process.env.SENTRY_DISABLED === 'true' || process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true' + process.env.XCODEBUILDMCP_SENTRY_DISABLED === 'true' || process.env.SENTRY_DISABLED === 'true' ); } diff --git a/src/utils/template-manager.ts b/src/utils/template-manager.ts index f5e3e128..b767a098 100644 --- a/src/utils/template-manager.ts +++ b/src/utils/template-manager.ts @@ -5,6 +5,7 @@ import { log } from './logger.ts'; import { iOSTemplateVersion, macOSTemplateVersion } from '../version.ts'; import { CommandExecutor } from './command.ts'; import { FileSystemExecutor } from './FileSystemExecutor.ts'; +import { getConfig } from './config-store.ts'; /** * Template manager for downloading and managing project templates @@ -23,16 +24,19 @@ export class TemplateManager { commandExecutor: CommandExecutor, fileSystemExecutor: FileSystemExecutor, ): Promise { - // Check for local override - const envVar = - platform === 'iOS' ? 'XCODEBUILDMCP_IOS_TEMPLATE_PATH' : 'XCODEBUILDMCP_MACOS_TEMPLATE_PATH'; - - const localPath = process.env[envVar]; - log('debug', `[TemplateManager] Checking env var '${envVar}'. Value: '${localPath}'`); + const config = getConfig(); + const localPath = platform === 'iOS' ? config.iosTemplatePath : config.macosTemplatePath; + log( + 'debug', + `[TemplateManager] Checking config override for ${platform} template. Value: '${localPath}'`, + ); if (localPath) { const pathExists = fileSystemExecutor.existsSync(localPath); - log('debug', `[TemplateManager] Env var set. Path '${localPath}' exists? ${pathExists}`); + log( + 'debug', + `[TemplateManager] Config override set. Path '${localPath}' exists? ${pathExists}`, + ); if (pathExists) { const templateSubdir = join(localPath, 'template'); const subdirExists = fileSystemExecutor.existsSync(templateSubdir); @@ -49,7 +53,7 @@ export class TemplateManager { } } - log('debug', '[TemplateManager] Env var not set or path invalid, proceeding to download.'); + log('debug', '[TemplateManager] No valid config override, proceeding to download.'); // Download from GitHub release return await this.downloadTemplate(platform, commandExecutor, fileSystemExecutor); } @@ -64,12 +68,11 @@ export class TemplateManager { ): Promise { const repo = platform === 'iOS' ? this.IOS_TEMPLATE_REPO : this.MACOS_TEMPLATE_REPO; const defaultVersion = platform === 'iOS' ? iOSTemplateVersion : macOSTemplateVersion; - const envVarName = - platform === 'iOS' - ? 'XCODEBUILD_MCP_IOS_TEMPLATE_VERSION' - : 'XCODEBUILD_MCP_MACOS_TEMPLATE_VERSION'; + const config = getConfig(); const version = String( - process.env[envVarName] ?? process.env.XCODEBUILD_MCP_TEMPLATE_VERSION ?? defaultVersion, + platform === 'iOS' + ? (config.iosTemplateVersion ?? defaultVersion) + : (config.macosTemplateVersion ?? defaultVersion), ); // Create temp directory for download diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts index 7c597d32..1fd4fcdd 100644 --- a/src/utils/tool-registry.ts +++ b/src/utils/tool-registry.ts @@ -67,7 +67,7 @@ export async function applyWorkflowSelection(workflowNames: string[]): Promise( schema: z.ZodType, @@ -94,7 +94,7 @@ export function getSessionAwareToolSchemaShape(opts: { sessionAware: z.ZodObject; legacy: z.ZodObject; }): ToolSchemaShape { - return isSessionDefaultsSchemaOptOutEnabled() ? opts.legacy.shape : opts.sessionAware.shape; + return isSessionDefaultsOptOutEnabled() ? opts.legacy.shape : opts.sessionAware.shape; } export function createSessionAwareTool(opts: { @@ -190,7 +190,7 @@ function createSessionAwareHandler(opts: { const { title, body } = formatRequirementError({ message: req.message ?? `Required: ${req.allOf.join(', ')}`, setHint, - optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(), + optOutEnabled: isSessionDefaultsOptOutEnabled(), }); return createErrorResponse(title, body); } @@ -204,7 +204,7 @@ function createSessionAwareHandler(opts: { const { title, body } = formatRequirementError({ message: req.message ?? `Provide one of: ${options}`, setHint: `Set with: ${setHints}`, - optOutEnabled: isSessionDefaultsSchemaOptOutEnabled(), + optOutEnabled: isSessionDefaultsOptOutEnabled(), }); return createErrorResponse(title, body); } diff --git a/src/utils/workflow-selection.ts b/src/utils/workflow-selection.ts index e9931db1..dc1de3da 100644 --- a/src/utils/workflow-selection.ts +++ b/src/utils/workflow-selection.ts @@ -1,8 +1,10 @@ import type { WorkflowGroup } from '../core/plugin-types.ts'; +import { getConfig } from './config-store.ts'; export const REQUIRED_WORKFLOW = 'session-management'; export const WORKFLOW_DISCOVERY_WORKFLOW = 'workflow-discovery'; export const DEBUG_WORKFLOW = 'doctor'; +export const DEFAULT_WORKFLOW = 'simulator'; type WorkflowName = string; @@ -15,13 +17,11 @@ function isWorkflowGroup(value: WorkflowGroup | undefined): value is WorkflowGro } export function isDebugEnabled(): boolean { - const value = process.env.XCODEBUILDMCP_DEBUG ?? ''; - return value.toLowerCase() === 'true' || value === '1'; + return getConfig().debug; } export function isWorkflowDiscoveryEnabled(): boolean { - const value = process.env.XCODEBUILDMCP_EXPERIMENTAL_WORKFLOW_DISCOVERY ?? ''; - return value.toLowerCase() === 'true' || value === '1'; + return getConfig().experimentalWorkflowDiscovery; } /** @@ -36,7 +36,7 @@ export function resolveSelectedWorkflowNames( availableWorkflowNames: WorkflowName[] = [], ): { selectedWorkflowNames: WorkflowName[]; - selectedNames: WorkflowName[] | null; + selectedNames: WorkflowName[]; } { const normalizedNames = normalizeWorkflowNames(workflowNames); const baseAutoSelected = [REQUIRED_WORKFLOW]; @@ -49,24 +49,14 @@ export function resolveSelectedWorkflowNames( baseAutoSelected.push(DEBUG_WORKFLOW); } - let selectedNames: WorkflowName[] | null = null; - if (normalizedNames.length > 0) { - selectedNames = [...new Set([...baseAutoSelected, ...normalizedNames])]; - } + // When no workflows specified, default to simulator workflow + const effectiveNames = normalizedNames.length > 0 ? normalizedNames : [DEFAULT_WORKFLOW]; + const selectedNames = [...new Set([...baseAutoSelected, ...effectiveNames])]; // Filter selected names to only include workflows that match real workflows. - let selectedWorkflowNames: WorkflowName[]; - if (selectedNames) { - selectedWorkflowNames = selectedNames.filter((workflowName) => - availableWorkflowNames.includes(workflowName), - ); - } else if (isWorkflowDiscoveryEnabled()) { - selectedWorkflowNames = [...availableWorkflowNames]; - } else { - selectedWorkflowNames = availableWorkflowNames.filter( - (workflowName) => workflowName !== WORKFLOW_DISCOVERY_WORKFLOW, - ); - } + const selectedWorkflowNames = selectedNames.filter((workflowName) => + availableWorkflowNames.includes(workflowName), + ); return { selectedWorkflowNames, selectedNames }; } @@ -84,7 +74,7 @@ export function resolveSelectedWorkflows( workflowGroupsParam?: Map, ): { selectedWorkflows: WorkflowGroup[]; - selectedNames: WorkflowName[] | null; + selectedNames: WorkflowName[]; } { const resolvedWorkflowGroups = workflowGroupsParam ?? new Map(); const availableWorkflowNames = [...resolvedWorkflowGroups.keys()]; diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts index e0e817ac..5affbf53 100644 --- a/src/utils/xcodemake.ts +++ b/src/utils/xcodemake.ts @@ -19,6 +19,7 @@ import { existsSync, readdirSync } from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; +import { getConfig } from './config-store.ts'; // Environment variable to control xcodemake usage export const XCODEMAKE_ENV_VAR = 'INCREMENTAL_BUILDS_ENABLED'; @@ -31,8 +32,7 @@ let overriddenXcodemakePath: string | null = null; * @returns boolean indicating if xcodemake should be used */ export function isXcodemakeEnabled(): boolean { - const envValue = process.env[XCODEMAKE_ENV_VAR]; - return envValue === '1' || envValue === 'true' || envValue === 'yes'; + return getConfig().incrementalBuildsEnabled; } /**