diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md new file mode 100644 index 00000000..7627e075 --- /dev/null +++ b/Docs/xcode-27-headless-mcp-design.md @@ -0,0 +1,283 @@ +# Xcode 27 Headless MCP Design + +## Status + +- Design owner: `codex/xcode-27-headless-mcp` +- Baseline: `b251cecfa059ce7b5f0a9c9a8b7f9480e390edb3` +- Verified Xcode: 27.0 build `27A5252f` +- Verified Xcode MCP server: `xcode-tools` version `25295.11` +- Implementation: complete; automated validation complete +- Live workspace validation: pending manual Xcode Service approval + +This document is the design contract and progress ledger for Xcode 27 headless +MCP support. Update it before changing a public API or owner boundary. + +## Consumer stories + +### Default CLI + +```sh +xcode-mcp-proxy-server --auto-approve +``` + +The server uses Xcode 27's headless MCP service when the selected Xcode ships +`mcp-server` and headless access is enabled. Otherwise it preserves GUI Xcode +routing. When headless access is available but disabled, startup emits one +actionable notice and continues with GUI routing. + +### Explicit selection + +```sh +xcode-mcp-proxy-server --xcode-mode gui +xcode-mcp-proxy-server --xcode-mode headless +``` + +Explicit GUI mode never consults or launches the headless service. Explicit +headless mode fails startup when the selected Xcode does not ship `mcp-server` +or headless access is disabled. It never silently falls back to GUI routing. + +### Embedded server + +```swift +let server = XcodeMCPProxyServer( + configuration: .init(xcodeMode: .automatic) +) +let endpoint = try await server.start() +defer { Task { try? await server.shutdown() } } +``` + +`xcodeMode` is a connection-selection policy. The existing `Upstream` value +continues to own the bridge command, arguments, pool size, and optional MCP +session identifier. + +## Public interface sketch + +```swift +public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { + public enum XcodeMode: String, Equatable, Sendable { + case automatic + case gui + case headless + } + + public var xcodeMode: XcodeMode + + public init( + bindAddress: BindAddress = .localhost(), + upstream: Upstream = .defaultMCPBridge(), + maxBodyBytes: Int = 1_048_576, + requestTimeout: Duration? = .seconds(300), + configurationFileURL: URL? = nil, + toolPolicy: ToolPolicy? = nil, + initializeHandshake: InitializeHandshake? = nil, + discovery: Discovery = .defaultLocation, + approvalPolicy: ApprovalPolicy = .manual, + featurePolicy: FeaturePolicy = .default, + xcodeMode: XcodeMode = .automatic + ) +} +``` + +Adding `xcodeMode` with a default preserves existing source call sites. The new +enum is a closed consumer choice and is therefore intentionally exhaustive. + +## Verified upstream contract + +The following facts were observed with `MCP_XCODE_PID` absent and the selected +Xcode 27 developer directory in `DEVELOPER_DIR`: + +1. `xcrun mcp-server status --format json` exits successfully while disabled + and returns `permission.enabled`, `permission.unsafeAlwaysAllowAllAgents`, + `running`, and `openWorkspaces`. +2. `openWorkspaces` is not a stable scalar shape: when populated it is an array + of objects containing `path`, `displayName`, and `activeSchemeName`. + Availability resolution therefore decodes only `permission.enabled` and + preserves or ignores unknown fields. +3. An unbound `mcpbridge` initializes successfully against headless + `XcodeService` and returns a 54-tool catalog. +4. The headless catalog owns workspace lifecycle through + `XcodeOpenWorkspace`, `XcodeListWorkspaces`, and `XcodeCloseWorkspace`. + XcodeMCPKit must not duplicate that state or require a workspace path at + proxy startup. +5. First use of `XcodeOpenWorkspace` is the approval boundary for both agent + identity and the containing folder. Before approval, catalog discovery is + available while workspace tools return an actionable tool error. +6. `mcp-server open ` is service administration, not the agent approval + boundary. It must not be used as a substitute for `XcodeOpenWorkspace`. +7. Xcode Service is process-shared. XcodeMCPKit owns its child `mcpbridge` + processes, but does not own or stop Xcode Service. +8. With headless access enabled and Xcode Service stopped, launching an unbound + `mcpbridge` starts Xcode Service and completes `initialize`. XcodeMCPKit does + not need to call `mcp-server start`. + +The preview CLI may return valid status JSON together with a nonzero status or +warning when its live service query times out. A valid JSON payload is the +status fact; stderr and exit status remain diagnostics. + +## Mode resolution + +Mode resolution happens once during server start, before the runtime and HTTP +gateway acquire resources. + +| Requested mode | Stock `mcpbridge` | `mcp-server` state | Effective mode | +| --- | --- | --- | --- | +| automatic | yes | installed and enabled | headless | +| automatic | yes | installed and disabled | GUI + notice | +| automatic | yes | not installed | GUI | +| automatic | yes | status unavailable or malformed | GUI + warning | +| gui | yes | any | GUI; status is not queried | +| headless | yes | installed and enabled | headless | +| headless | yes | disabled, unavailable, or malformed | startup error | +| automatic | custom upstream | not applicable | existing custom unbound mode | +| gui/headless | custom upstream | not applicable | configuration error | + +The disabled notice is one multiline log event: + +```text +Xcode 27 headless MCP is available but disabled. + +To enable it, run: + + sudo xcrun mcp-server enable + +XcodeMCPKit will continue using GUI Xcode routing. +``` + +XcodeMCPKit never executes `enable`, `approve`, `allow-folder`, `deny`, +`clear-permissions`, or an unsafe permission command. + +## Owner map + +| Responsibility | Owner | +| --- | --- | +| Requested GUI/headless/automatic policy | `XcodeMCPProxyServerConfiguration` / `ProxyConfig` | +| `mcp-server` discovery, status execution, and narrow JSON decoding | new internal status client in `XcodeMCPProxyKit` | +| Effective mode selection and user-facing notice/error | server lifecycle acquisition | +| GUI Xcode process inventory | existing `XcodeProcessEventMonitor` | +| GUI process-bound bridge membership and catalogs | existing `ProcessControlPlaneAuthority` | +| Headless bridge process and catalog | existing unbound `MCPBridgeRuntime` path | +| Headless workspace membership and identifiers | upstream Xcode Service tools | +| GUI window/tab identity | existing `WindowOwnershipAuthority` | +| Device interaction token affinity | new runtime affinity authority | +| Downstream HTTP session and progress-token ownership | existing session and lease authorities | + +No new package, product, or target is required. The new external-I/O adapter is +an internal `XcodeMCPProxyKit` responsibility; the runtime receives only the +resolved mode. + +## Runtime and lifecycle contract + +- GUI mode preserves process-bound discovery, `MCP_XCODE_PID`, per-Xcode pools, + AX permission automation, and the proxy DocumentationSearch provider. +- Headless mode launches the configured stock bridge without + `MCP_XCODE_PID`. It does not wait for a GUI Xcode process and does not run AX + permission automation. +- Headless mode forwards the upstream DocumentationSearch and workspace tools; + it does not create a second workspace or documentation source of truth. +- Proxy shutdown closes and awaits its bridge/runtime/HTTP resources. It does + not call `mcp-server stop`. +- Status resolution is part of startup acquisition. Cancellation of startup + cancels and awaits the status process through `ProcessRunner`. +- A disabled headless service is a normal automatic-mode candidate result. A + malformed response or execution failure is diagnostic, not silently + equivalent to disabled. + +## Tool-surface compatibility + +The Xcode tool catalog remains dynamic. Do not add one Swift method per Xcode +tool. Headless-specific tools and future catalog fields pass through unchanged. + +The proxy-owned `XcodeRefreshCodeIssuesInFile` workflow must be checked against +the headless schema before it is enabled in headless mode. If the headless tool +uses `workspaceIdentifier` rather than the GUI tab contract, the effective +headless configuration forwards this tool upstream instead of guessing a GUI +owner. + +## Device interaction affinity + +`DeviceInteractionStartSession` and +`DeviceInteractionStartWorkspaceSession` return `interactionSessionKey`. +Follow-up tools use two spellings: + +- `DeviceInteractionSynthesize`: `interactSessionKey` +- `DeviceInteractionInstallAndRun` and `DeviceInteractionEndSession`: + `interactionSessionKey` + +For every upstream topology, the runtime records the returned key together with +the exact upstream topology proof that created it. Routed GUI pools additionally +record the stable process-route identity needed for window admission and +identifier rewriting. Follow-up requests are admitted only to the recorded +upstream proof. Route replacement, retirement, session end, and runtime shutdown +evict the corresponding affinity. An unknown key follows the upstream's ordinary +error path only when a single unbound upstream exists; it is never guessed across +multiple process-routed or unbound upstreams. + +The affinity authority owns token membership. Request routing consumes an +immutable snapshot/proof and revalidates it before send. It does not mirror +device state or own the device-session lifecycle itself. + +## Progress and verifier contract + +- Existing progress-token rewriting and per-operation delivery remain the + single source of truth. +- The live verifier records progress notifications for build/test operations + and preserves their raw fields in its report. +- The verifier gains a headless path that calls `XcodeOpenWorkspace`, uses the + returned `workspaceIdentifier`, and always calls `XcodeCloseWorkspace` for a + workspace it opened. +- Live verification remains opt-in and never enables or broadly approves + headless access. + +## Signing decision + +The current release artifact is ad-hoc signed. Xcode Service identified the +probe's actual host executable as the agent identity, not `mcpbridge`. After a +release-shaped XcodeMCPKit binary connects headlessly, inspect the recorded +identity and approval duration. Developer ID signing and notarization are a +follow-up only if durable trust rejects the artifact or fails to survive an +upgrade. No signing credential or workflow change is part of this design until +that behavior is observed. + +## Failure semantics + +| Boundary | Behavior | +| --- | --- | +| `mcp-server` absent in automatic mode | use GUI routing | +| headless disabled in automatic mode | emit notice once; use GUI routing | +| status command fails or JSON is malformed in automatic mode | emit warning; use GUI routing | +| explicit headless unavailable or disabled | fail startup with actionable configuration error | +| agent/folder approval pending | preserve upstream tool error; do not auto-approve or retry-loop | +| headless service exits after connection | existing upstream health/recovery semantics apply | +| proxy shuts down | stop owned bridges; leave shared Xcode Service running | + +## Validation + +- Status-client unit tests: unavailable, disabled, enabled, populated dynamic + `openWorkspaces`, valid JSON with nonzero exit, malformed JSON, timeout, and + cancellation. +- CLI/config tests for all modes and custom-upstream conflicts. +- Runtime tests proving GUI mode remains process-bound and headless mode is + unbound with no GUI readiness launch. +- Startup-summary and exact multiline notice tests. +- Public product contract compile test for `xcodeMode`. +- Device-affinity owner and routing tests, including both key spellings, + process-routed and unbound pools, replacement, retirement, end, and unknown + keys. +- Existing fast, process, adapter, and full maintainer checks. +- Opt-in live headless initialize, catalog, workspace open/list/close, progress, + and shutdown verification against Xcode 27. + +## Progress ledger + +- [x] Create task branch and record baseline. +- [x] Verify status JSON while disabled and enabled. +- [x] Verify headless initialize and 54-tool catalog. +- [x] Verify workspace tools are the approval/bootstrap boundary. +- [x] Verify unbound `mcpbridge` starts Xcode Service on demand. +- [x] Implement mode/status resolution and notice. +- [x] Implement resolved runtime ownership and public/CLI surface. +- [x] Implement device interaction affinity. +- [x] Extend verifier and documentation. +- [x] Run automated validation and clean `codex-review`. +- [ ] Complete post-approval live workspace open/list/close and progress + verification. diff --git a/Docs/xcode-27-mcpbridge-tools.md b/Docs/xcode-27-mcpbridge-tools.md index 838478a9..92562a38 100644 --- a/Docs/xcode-27-mcpbridge-tools.md +++ b/Docs/xcode-27-mcpbridge-tools.md @@ -1,19 +1,99 @@ -# Xcode 27 mcpbridge Tool Additions +# Xcode 27 mcpbridge Tool Surfaces ## Verified Environment - Xcode 26.6: `/Applications/Xcode.app`, build `17F109`, `xcode-tools` server version `24950` -- Xcode 27.0: `/Applications/Xcode_27.app`, build `27A5209h`, `xcode-tools` server version `25245.3` +- Xcode 27.0 GUI baseline: `/Applications/Xcode_27.app`, build `27A5209h`, `xcode-tools` server version `25245.3` +- Xcode 27.0 headless: `/Applications/Xcode_27.app`, build `27A5252f`, `xcode-tools` server version `25295.11` - MCP protocol: `2025-06-18` -Xcode 26.6's `tools/list` returned 21 tools. Xcode 27.0's `tools/list` -returned 43 tools. No tools were removed; 22 tools were added. +Xcode 26.6's `tools/list` returned 21 tools. The Xcode 27 GUI baseline returned +43 tools: no Xcode 26.6 tool was removed and 22 tools were added. The newer +Xcode 27 headless server returned 54 tools. Its catalog is not a strict +superset of the GUI catalog: it replaces GUI window ownership with workspace +lifecycle tools and omits two other GUI-only queries. This document is based on the raw `tools/list` descriptors, the `mcp__xcode` tool metadata exposed to Codex, and light read-oriented runtime checks against the Xcode 27 MCP server. -## Added Tools +## Headless Workspace Surface + +With `MCP_XCODE_PID` absent, an unbound `mcpbridge` connected to Xcode Service +and advertised 54 tools. Relative to the 43-tool GUI baseline, the headless +catalog adds these 14 tools: + +| Category | Tool | Primary purpose | +| --- | --- | --- | +| Workspace lifecycle | `XcodeOpenWorkspace` | Open a project or workspace for this agent and return its identifier | +| Workspace lifecycle | `XcodeListWorkspaces` | List workspaces currently available through Xcode Service | +| Workspace lifecycle | `XcodeCloseWorkspace` | Close one workspace by identifier | +| Target inspection | `XcodeListTargets` | List targets in the selected workspace | +| Target inspection | `GetTargetBuildSettings` | Read target build settings | +| Target mutation | `UpdateTargetBuildSetting` | Change one target build setting | +| Target mutation | `AddEntitlement` | Add an entitlement through Xcode's project model | +| Target mutation | `AddInfoPlist` | Add or update an Info.plist entry through Xcode's project model | +| Project creation | `XcodeListTemplates` | List templates available for project or target creation | +| Project creation | `XcodeNewProject` | Create a project from a template | +| Project creation | `XcodeNewTarget` | Add a target from a template | +| Test plans | `XcodeListTestPlans` | List test plans in the selected workspace | +| Test plans | `XcodeSwitchTestPlan` | Change the active test plan | +| Device interaction | `DeviceInteractionStartWorkspaceSession` | Start device interaction from a headless workspace | + +The headless catalog does not advertise `XcodeListWindows`, +`XcodeGetCurrentFile`, or `XcodeListNavigatorIssues`. These depend on GUI +window, editor, or navigator state. Consumers must detect +`XcodeListWorkspaces` versus `XcodeListWindows` from `tools/list`; Xcode version +strings are not a reliable routing contract. + +### Workspace lifecycle and approval bootstrap + +The verified headless lifecycle is: + +1. Call `tools/list`; catalog discovery does not require an open workspace. +2. Call `XcodeOpenWorkspace` with required `path`. +3. Keep the returned `workspaceIdentifier` and optional `workspacePath`. +4. Pass that identifier to workspace-scoped tools when their current schema + advertises `workspaceIdentifier`. +5. Call `XcodeCloseWorkspace` with the same identifier only if this client + opened the workspace. + +`XcodeOpenWorkspace` is also the first-use approval boundary for the agent +identity and containing folder. Before approval, `XcodeListWorkspaces` and +other workspace calls can return an actionable tool error directing the caller +to open or create a workspace. `xcrun mcp-server open ` administers the +shared service but does not replace this approval bootstrap. + +XcodeMCPKit never enables headless access, approves an agent or folder, broadens +permission policy, or stops Xcode Service. The service is process-shared; a +client owns only its `mcpbridge` child and workspace handles it explicitly +acquired. + +### Schema differences + +- GUI workspace tools use `tabIdentifier`; headless workspace tools advertise + optional `workspaceIdentifier`, described as either the identifier returned + by `XcodeOpenWorkspace` or an absolute workspace path. +- `XcodeOpenWorkspace` requires `path` and returns required + `workspaceIdentifier` plus optional `workspacePath`, `activeScheme`, + `activeRunDestination`, and `message` fields. +- `XcodeListWorkspaces` takes no arguments. +- `XcodeCloseWorkspace` requires `workspaceIdentifier`. +- `DeviceInteractionStartWorkspaceSession` requires `sessionIdentifier`, + accepts optional `workspaceIdentifier`, and returns + `interactionSessionKey`. +- Follow-up device calls retain two key spellings: + `DeviceInteractionSynthesize` requires `interactSessionKey`, while + `DeviceInteractionInstallAndRun` and `DeviceInteractionEndSession` require + `interactionSessionKey`. + +The live verifier writes the full raw headless catalog to +`ProxyToolVerifierOutput/headless-tool-catalog.json`. It validates a planned +call's arguments against that run's input schema, reports unknown or unsafe +tools as `not-planned`, and preserves raw `MCPProgress` payloads for build and +test operations in `report.json`. + +## Xcode 27 GUI Additions over Xcode 26.6 | Category | Tool | Primary purpose | | --- | --- | --- | @@ -336,7 +416,9 @@ valid version and signature. ## Practical Notes -- Most added tools require `tabIdentifier`. Start with `XcodeListWindows` to select the target workspace tab. +- In the GUI catalog, most tools require `tabIdentifier`; start with + `XcodeListWindows`. In the headless catalog, open the workspace and use the + returned `workspaceIdentifier` only where the advertised schema accepts it. - `XcodeSwitchScheme` and `XcodeSwitchRunDestination` change Xcode UI state. Use list tools for inspection-only workflows. - `RunProject`, `DeviceInteraction*`, `StringCatalogEdit`, and `UpdateFileCompilerFlags` change project, device, or file state. Prefer fixtures or scratch projects when validating them. - Organizer diagnostics may succeed at the MCP transport level while returning structured `success=false` for product, platform, App Store Connect, or Organizer availability issues. Treat this separately from an MCP error. diff --git a/README.md b/README.md index e149fff3..15e4fbc7 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,30 @@ source ~/.zshrc ### 1. Enable Xcode MCP Access -Open your project in Xcode, choose **Xcode > Settings > Intelligence**, and turn -on **Allow external agents to use Xcode tools** under **Model Context Protocol**. +XcodeMCPKit automatically uses Xcode 27's headless MCP service when it is +available and enabled. This lets the proxy start before a project or workspace +is open in the Xcode app. Enabling the service is an optional, one-time system +setup performed by you: + +```bash +sudo xcrun mcp-server enable +``` + +XcodeMCPKit never runs `sudo` or changes Xcode MCP permissions. If Xcode 27 +provides the service but it is disabled, startup prints the command above and +continues with GUI Xcode routing. Older Xcode versions also continue with GUI +routing. + +For GUI routing, open your project in Xcode, choose +**Xcode > Settings > Intelligence**, and turn on +**Allow external agents to use Xcode tools** under **Model Context Protocol**. See [Giving external agents access to Xcode][apple-xcode-mcp-access]. This global Xcode setting is separate from the per-connection **Allow** dialog. -`--auto-approve` handles the dialog; it does not enable Xcode MCP access. +`--auto-approve` handles the GUI dialog; it does not enable headless MCP access. +The first headless `XcodeOpenWorkspace` call can separately ask you to approve +the agent and containing folder. Review that request in Xcode Service and +approve it manually; XcodeMCPKit does not broaden headless permissions. ### 2. Start the Proxy Server @@ -72,7 +90,7 @@ This global Xcode setting is separate from the per-connection **Allow** dialog. xcode-mcp-proxy-server --auto-approve ``` -`--auto-approve` clicks the Xcode **Allow** button automatically. In +In GUI mode, `--auto-approve` clicks the Xcode **Allow** button automatically. In **System Settings > Privacy & Security > Accessibility**, allow the app that launches the proxy (for example, Terminal or iTerm). @@ -125,9 +143,10 @@ xcode-mcp-proxy --help |--------|-------------| | `--listen host:port` | Listen address. Defaults to `localhost:8765`. | | `--host host` / `--port port` | Listen host and port when `--listen` is not used. | -| `--upstream-processes n` | Number of upstream `mcpbridge` processes per running Xcode process when the default `xcrun mcpbridge` upstream is used. Default: `1`, max: `10`. | +| `--upstream-processes n` | Upstream `mcpbridge` count: per running Xcode in GUI mode, or total unbound pool size in headless/custom mode. Default: `1`, max: `10`. | | `--request-timeout seconds` | Request timeout. `0` disables non-initialize timeouts; initialize still has a bounded handshake timeout. | | `--config path` | TOML config path. | +| `--xcode-mode automatic|gui|headless` | Select Xcode routing. `automatic` (default) uses enabled headless MCP when available and otherwise uses GUI routing. `headless` fails instead of falling back. | | `--auto-approve` | Automatically approve the Xcode permission dialog. Requires Accessibility permission. | | `--refresh-code-issues-mode proxy|upstream` | Serve `XcodeRefreshCodeIssuesInFile` through proxy diagnostics (`proxy`, default) or pass through to Xcode live diagnostics (`upstream`). | | `--force-restart` | Terminate an existing `xcode-mcp-proxy-server` on the listen port and start a new one. | @@ -138,7 +157,7 @@ xcode-mcp-proxy --help |----------|-------------| | `LISTEN` | Listen address, for example `127.0.0.1:8765`. | | `HOST` / `PORT` | Listen host and port when `LISTEN` is unset. | -| `MCP_XCODE_PID` | Set by the proxy on process-bound upstream `mcpbridge` children. An inherited value is only passed through when process-bound Xcode routing is not active. | +| `MCP_XCODE_PID` | Set by the proxy on GUI process-bound upstream `mcpbridge` children. Headless routing leaves the stock bridge unbound. An inherited value is only passed through when process-bound Xcode routing is not active. | | `MCP_XCODE_SESSION_ID` | Optional explicit upstream Xcode MCP session ID. | | `MCP_XCODE_CONFIG` | TOML config path. `--config` takes precedence. | | `MCP_XCODE_REFRESH_CODE_ISSUES_MODE` | `proxy` or `upstream`. | diff --git a/Sources/XcodeMCPProxyKit/Internal/CLI/Server/ProxyServerCommand.swift b/Sources/XcodeMCPProxyKit/Internal/CLI/Server/ProxyServerCommand.swift index 61fe8877..d74cae44 100644 --- a/Sources/XcodeMCPProxyKit/Internal/CLI/Server/ProxyServerCommand.swift +++ b/Sources/XcodeMCPProxyKit/Internal/CLI/Server/ProxyServerCommand.swift @@ -67,6 +67,9 @@ package struct ProxyServerCommand: ParsableCommand { @Option(help: "Explicit upstream Xcode MCP session identifier.") var sessionID: String? + @Option(help: "Xcode connection mode: automatic, gui, or headless.") + var xcodeMode: ProxyConfig.XcodeMode = .automatic + @Option(help: "Code issue refresh owner: proxy or upstream.") var refreshCodeIssuesMode: ProxyConfig.RefreshCodeIssuesMode? @@ -134,3 +137,4 @@ package struct CLIListenAddress: Equatable, Sendable, CustomStringConvertible, } extension ProxyConfig.RefreshCodeIssuesMode: ExpressibleByArgument {} +extension ProxyConfig.XcodeMode: ExpressibleByArgument {} diff --git a/Sources/XcodeMCPProxyKit/Internal/Configuration/ProxyConfig.swift b/Sources/XcodeMCPProxyKit/Internal/Configuration/ProxyConfig.swift index 9aa937c9..435b56aa 100644 --- a/Sources/XcodeMCPProxyKit/Internal/Configuration/ProxyConfig.swift +++ b/Sources/XcodeMCPProxyKit/Internal/Configuration/ProxyConfig.swift @@ -3,6 +3,17 @@ import XcodeMCPKit import XcodeMCPProxyRuntime package struct ProxyConfig: Sendable { + package enum XcodeMode: String, Sendable { + case automatic + case gui + case headless + } + + package enum UpstreamKind: Sendable { + case stockMCPBridge + case custom + } + package enum RefreshCodeIssuesMode: String, Sendable { case proxy case upstream @@ -29,6 +40,8 @@ package struct ProxyConfig: Sendable { package var upstreamArgs: [String] package var upstreamProcessCount: Int package var upstreamSessionID: String? + package var upstreamKind: UpstreamKind + package var xcodeMode: XcodeMode package var maxBodyBytes: Int package var requestTimeout: TimeInterval package var configPath: String? @@ -46,6 +59,8 @@ package struct ProxyConfig: Sendable { upstreamArgs: [String], upstreamProcessCount: Int = 1, upstreamSessionID: String? = nil, + upstreamKind: UpstreamKind? = nil, + xcodeMode: XcodeMode = .automatic, maxBodyBytes: Int, requestTimeout: TimeInterval, configPath: String? = nil, @@ -62,6 +77,11 @@ package struct ProxyConfig: Sendable { self.upstreamArgs = upstreamArgs self.upstreamProcessCount = upstreamProcessCount self.upstreamSessionID = upstreamSessionID + self.upstreamKind = upstreamKind ?? Self.inferredUpstreamKind( + command: upstreamCommand, + arguments: upstreamArgs + ) + self.xcodeMode = xcodeMode self.maxBodyBytes = maxBodyBytes self.requestTimeout = requestTimeout self.configPath = configPath @@ -103,6 +123,25 @@ package struct ProxyConfig: Sendable { } } + package func validateXcodeModeConfiguration() throws { + guard upstreamKind == .stockMCPBridge || xcodeMode == .automatic else { + throw XcodeMCPProxyServer.LifecycleError.invalidConfiguration( + "xcodeMode must be automatic when using a custom upstream" + ) + } + } + + private static func inferredUpstreamKind( + command: String, + arguments: [String] + ) -> UpstreamKind { + let invocation = MCPBridgeInvocation.defaultMCPBridge + if command == invocation.command, arguments == invocation.arguments { + return .stockMCPBridge + } + return .custom + } + static func normalizedToolNames(_ names: S) -> Set where S.Element == String @@ -118,8 +157,22 @@ package struct ProxyConfig: Sendable { return normalized } - package var runtimeConfiguration: ProxyRuntimeConfiguration { - ProxyRuntimeConfiguration( + package func runtimeConfiguration( + xcodeMode: ProxyRuntimeConfiguration.XcodeMode + ) -> ProxyRuntimeConfiguration { + let effectiveRefreshCodeIssuesMode: ProxyRuntimeConfiguration.RefreshCodeIssuesMode + if xcodeMode == .headless { + // The proxy workflow resolves GUI tab identity and navigator state. + // Headless workspace identity belongs to Xcode Service, so preserve + // the upstream tool contract instead of manufacturing a GUI owner. + effectiveRefreshCodeIssuesMode = .upstream + } else { + effectiveRefreshCodeIssuesMode = ProxyRuntimeConfiguration.RefreshCodeIssuesMode( + refreshCodeIssuesMode + ) + } + return ProxyRuntimeConfiguration( + xcodeMode: xcodeMode, upstreamCommand: upstreamCommand, upstreamArgs: upstreamArgs, upstreamProcessCount: upstreamProcessCount, @@ -127,10 +180,8 @@ package struct ProxyConfig: Sendable { maxMessageBytes: maxBodyBytes, requestTimeout: requestTimeout, prewarmToolsList: prewarmToolsList, - usesPermissionDialogAutomation: autoApproveXcodeDialog, - refreshCodeIssuesMode: ProxyRuntimeConfiguration.RefreshCodeIssuesMode( - refreshCodeIssuesMode - ), + usesPermissionDialogAutomation: autoApproveXcodeDialog && xcodeMode != .headless, + refreshCodeIssuesMode: effectiveRefreshCodeIssuesMode, disabledToolNames: disabledToolNames, initializeParamsOverride: initializeParamsOverride.map( ProxyRuntimeConfiguration.InitializeHandshakeOverride.init diff --git a/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift b/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift new file mode 100644 index 00000000..a50b22af --- /dev/null +++ b/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift @@ -0,0 +1,244 @@ +import Foundation +import XcodeMCPKit +import XcodeMCPProxyRuntime + +enum XcodeMCPServerAvailability: Equatable, Sendable { + case unavailable + case disabled + case enabled +} + +struct XcodeMCPServerStatusClient: Sendable { + enum Failure: Error, Equatable, CustomStringConvertible, Sendable { + case discoveryFailed(exitStatus: Int32, stderr: String) + case discoveryReturnedNoPath + case statusFailed(exitStatus: Int32, stderr: String) + case malformedStatus + case timedOut(operation: String) + case executionFailed(operation: String, message: String) + + var description: String { + switch self { + case .discoveryFailed(let exitStatus, let stderr): + return Self.processFailureDescription( + operation: "discover mcp-server", + exitStatus: exitStatus, + stderr: stderr + ) + case .discoveryReturnedNoPath: + return "xcrun --find mcp-server returned no executable path" + case .statusFailed(let exitStatus, let stderr): + return Self.processFailureDescription( + operation: "read mcp-server status", + exitStatus: exitStatus, + stderr: stderr + ) + case .malformedStatus: + return "mcp-server returned malformed status JSON" + case .timedOut(let operation): + return "\(operation) timed out" + case .executionFailed(let operation, let message): + return "\(operation) failed: \(message)" + } + } + + private static func processFailureDescription( + operation: String, + exitStatus: Int32, + stderr: String + ) -> String { + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if detail.isEmpty { + return "\(operation) exited with status \(exitStatus)" + } + return "\(operation) exited with status \(exitStatus): \(detail)" + } + } + + private struct StatusPayload: Decodable { + struct Permission: Decodable { + let enabled: Bool + } + + let permission: Permission + } + + static let toolNotFoundExitStatus: Int32 = 72 + static let discoveryTimeoutNanoseconds: Int64 = 5_000_000_000 + static let statusTimeoutNanoseconds: Int64 = 15_000_000_000 + + private let processRunner: any ProcessRunning + + init(processRunner: any ProcessRunning = ProcessRunner()) { + self.processRunner = processRunner + } + + func availability() async throws -> XcodeMCPServerAvailability { + let discovery = try await run( + operation: "mcp-server discovery", + request: ProcessRequest( + label: "discover-xcode-mcp-server", + executablePath: MCPBridgeInvocation.xcrunCommand, + arguments: ["--find", "mcp-server"], + input: nil, + timeoutNanoseconds: Self.discoveryTimeoutNanoseconds + ) + ) + if discovery.terminationStatus == Self.toolNotFoundExitStatus { + return .unavailable + } + guard discovery.terminationStatus == 0 else { + throw Failure.discoveryFailed( + exitStatus: discovery.terminationStatus, + stderr: discovery.stderr + ) + } + let mcpServerPath = discovery.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard mcpServerPath.isEmpty == false else { + throw Failure.discoveryReturnedNoPath + } + + let status = try await run( + operation: "mcp-server status", + request: ProcessRequest( + label: "read-xcode-mcp-server-status", + executablePath: mcpServerPath, + arguments: ["status", "--format", "json"], + input: nil, + timeoutNanoseconds: Self.statusTimeoutNanoseconds + ) + ) + if let payload = try? JSONDecoder().decode( + StatusPayload.self, + from: Data(status.stdout.utf8) + ) { + return payload.permission.enabled ? .enabled : .disabled + } + guard status.terminationStatus == 0 else { + throw Failure.statusFailed( + exitStatus: status.terminationStatus, + stderr: status.stderr + ) + } + throw Failure.malformedStatus + } + + private func run( + operation: String, + request: ProcessRequest + ) async throws -> ProcessOutput { + do { + return try await processRunner.run(request) + } catch is CancellationError { + throw CancellationError() + } catch is ProcessTimeoutError { + throw Failure.timedOut(operation: operation) + } catch { + throw Failure.executionFailed( + operation: operation, + message: String(describing: error) + ) + } + } +} + +enum XcodeConnectionModeResolver { + enum Diagnostic: Equatable, Sendable { + case notice(String) + case warning(String) + } + + struct Resolution: Equatable, Sendable { + let xcodeMode: ProxyRuntimeConfiguration.XcodeMode + let diagnostic: Diagnostic? + } + + static let disabledNotice = """ + Xcode 27 headless MCP is available but disabled. + + To enable it, run: + + sudo xcrun mcp-server enable + + XcodeMCPKit will continue using GUI Xcode routing. + """ + + static func resolve( + config: ProxyConfig, + availability: @Sendable () async throws -> XcodeMCPServerAvailability + ) async throws -> Resolution { + if config.upstreamKind == .custom { + guard config.xcodeMode == .automatic else { + throw XcodeMCPProxyServer.LifecycleError.invalidConfiguration( + "xcodeMode must be automatic when using a custom upstream" + ) + } + return Resolution(xcodeMode: .custom, diagnostic: nil) + } + + switch config.xcodeMode { + case .gui: + return Resolution(xcodeMode: .gui, diagnostic: nil) + case .automatic: + do { + switch try await availability() { + case .unavailable: + return Resolution(xcodeMode: .gui, diagnostic: nil) + case .disabled: + return Resolution( + xcodeMode: .gui, + diagnostic: .notice(disabledNotice) + ) + case .enabled: + return Resolution(xcodeMode: .headless, diagnostic: nil) + } + } catch is CancellationError { + throw CancellationError() + } catch { + return Resolution( + xcodeMode: .gui, + diagnostic: .warning(automaticFailureWarning(error)) + ) + } + case .headless: + do { + switch try await availability() { + case .enabled: + return Resolution(xcodeMode: .headless, diagnostic: nil) + case .disabled: + throw XcodeMCPProxyServer.LifecycleError.invalidConfiguration( + explicitDisabledMessage + ) + case .unavailable: + throw XcodeMCPProxyServer.LifecycleError.invalidConfiguration( + "Xcode headless MCP is unavailable because the selected Xcode does not provide mcp-server." + ) + } + } catch is CancellationError { + throw CancellationError() + } catch let error as XcodeMCPProxyServer.LifecycleError { + throw error + } catch { + throw XcodeMCPProxyServer.LifecycleError.invalidConfiguration( + "Unable to determine Xcode headless MCP status: \(error)" + ) + } + } + } + + private static let explicitDisabledMessage = """ + Xcode headless MCP is disabled. + + To enable it, run: + + sudo xcrun mcp-server enable + """ + + private static func automaticFailureWarning(_ error: any Error) -> String { + """ + Unable to determine Xcode headless MCP status: \(error) + + XcodeMCPKit will continue using GUI Xcode routing. + """ + } +} diff --git a/Sources/XcodeMCPProxyKit/README.md b/Sources/XcodeMCPProxyKit/README.md index 32ffce05..fdafbbd5 100644 --- a/Sources/XcodeMCPProxyKit/README.md +++ b/Sources/XcodeMCPProxyKit/README.md @@ -23,7 +23,8 @@ let server = XcodeMCPProxyServer( upstream: .defaultMCPBridge(processesPerXcode: 1), requestTimeout: .seconds(300), discovery: .defaultLocation, - approvalPolicy: .manual + approvalPolicy: .manual, + xcodeMode: .automatic ) ) @@ -51,6 +52,8 @@ a new instance after shutdown. - `bindAddress`: host and port; port `0` requests an ephemeral port. - `upstream`: the default `xcrun mcpbridge` invocation or an explicit command. + Its `processesPerXcode` value is per GUI Xcode process; headless and custom + unbound routing use it as the total bridge-pool size. - `maxBodyBytes`: positive maximum HTTP request body size. - `requestTimeout`: a positive `Duration`, or `nil` to disable the timeout. - `configurationFileURL`: optional TOML file. An explicit unreadable or invalid @@ -60,6 +63,21 @@ a new instance after shutdown. - `discovery`: `.disabled`, `.defaultLocation`, or `.file(URL)`. - `approvalPolicy`: manual or automatic Xcode permission handling. - `featurePolicy`: tools-list prewarming and refresh-code-issues routing. +- `xcodeMode`: `.automatic` (the default), `.gui`, or `.headless` for the stock + `mcpbridge` upstream. Automatic mode selects the enabled Xcode 27 headless + service and otherwise preserves GUI routing. + +Headless mode does not require a workspace to be open in the Xcode app. It +forwards workspace lifecycle and DocumentationSearch tools to Xcode Service, +does not run GUI permission automation, and never enables, approves, or stops +the shared service. If headless access is disabled, enable it separately with +`sudo xcrun mcp-server enable`; explicit `.headless` fails startup instead of +silently falling back. Xcode Service can request manual agent and folder +approval on the first `XcodeOpenWorkspace` call; `approvalPolicy: .automatic` +applies only to GUI Xcode dialogs. + +Custom upstream commands keep their existing unbound behavior and require +`xcodeMode: .automatic`. ```swift import Foundation diff --git a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Launch.swift b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Launch.swift index d7494659..81202bb1 100644 --- a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Launch.swift +++ b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Launch.swift @@ -66,6 +66,7 @@ extension XcodeMCPProxyServer { do { proxyConfig = try command.resolveConfiguration(environment: environment) try proxyConfig.validateModernProtocolConfiguration() + try proxyConfig.validateXcodeModeConfiguration() } catch let error as CLICommandError { throw error } catch { @@ -124,6 +125,9 @@ private extension ProxyServerCommand { let refreshCodeIssuesMode = try resolvedRefreshCodeIssuesMode( environment: environment ) + let usesCustomUpstream = upstreamCommand != nil + || upstreamArgs != nil + || upstreamArg.isEmpty == false return ProxyConfig( listenHost: listenAddress.host, listenPort: listenAddress.port, @@ -131,6 +135,8 @@ private extension ProxyServerCommand { upstreamArgs: resolvedUpstreamArguments, upstreamProcessCount: upstreamProcesses ?? 1, upstreamSessionID: sessionID ?? nonEmpty(environment["MCP_XCODE_SESSION_ID"]), + upstreamKind: usesCustomUpstream ? .custom : .stockMCPBridge, + xcodeMode: xcodeMode, maxBodyBytes: maxBodyBytes ?? 1_048_576, requestTimeout: requestTimeout?.seconds ?? 300, configPath: config ?? nonEmpty(environment["MCP_XCODE_CONFIG"]), @@ -226,6 +232,9 @@ private extension ProxyServerCommand { if let sessionID = configuration.upstream.sessionID { arguments += ["--session-id", sessionID] } + if configuration.xcodeMode != .automatic { + arguments += ["--xcode-mode", configuration.xcodeMode.rawValue] + } if let refreshCodeIssuesMode { arguments += [ "--refresh-code-issues-mode", diff --git a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift index 2e34fa7f..af8bd318 100644 --- a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift +++ b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift @@ -19,19 +19,22 @@ extension XcodeMCPProxyServer { let runtime: any ProxyRuntimeServing let autoApprover: (any ProxyServerPermissionDialogAutoApprover)? let endpoint: Endpoint + let xcodeMode: ProxyRuntimeConfiguration.XcodeMode init( config: ProxyConfig, httpGateway: any ProxyHTTPGatewayServing, runtime: any ProxyRuntimeServing, autoApprover: (any ProxyServerPermissionDialogAutoApprover)?, - endpoint: Endpoint + endpoint: Endpoint, + xcodeMode: ProxyRuntimeConfiguration.XcodeMode ) { self.config = config self.httpGateway = httpGateway self.runtime = runtime self.autoApprover = autoApprover self.endpoint = endpoint + self.xcodeMode = xcodeMode } func signalCancellation() { @@ -87,6 +90,7 @@ extension XcodeMCPProxyServer { ) } try config.validateModernProtocolConfiguration() + try config.validateXcodeModeConfiguration() } catch { phase = .stopped throw error @@ -104,7 +108,11 @@ extension XcodeMCPProxyServer { let acquired: Resources do { - acquired = try await task.value + acquired = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } } catch { startupTask = nil phase = .stopped @@ -223,6 +231,7 @@ extension XcodeMCPProxyServer { phase = .stopped return } + startupTask.cancel() do { let acquired = try await startupTask.value self.startupTask = nil @@ -284,6 +293,7 @@ extension XcodeMCPProxyServer { displayHost: displayHost, port: resources.endpoint.port, config: resources.config, + xcodeMode: resources.xcodeMode, xcodeTargets: resources.runtime.inventorySnapshot().xcodeTargets ) logger.info("\(summary)") @@ -301,8 +311,16 @@ extension XcodeMCPProxyServer { dependencies: Dependencies, logger: Logger ) async throws -> Resources { - let runtime = dependencies.makeRuntime(config.runtimeConfiguration) - let autoApprover = config.autoApproveXcodeDialog + let modeResolution = try await XcodeConnectionModeResolver.resolve( + config: config, + availability: dependencies.headlessMCPAvailability + ) + logModeDiagnostic(modeResolution.diagnostic, logger: logger) + let runtimeConfiguration = config.runtimeConfiguration( + xcodeMode: modeResolution.xcodeMode + ) + let runtime = dependencies.makeRuntime(runtimeConfiguration) + let autoApprover = runtimeConfiguration.usesPermissionDialogAutomation ? dependencies.makeAutoApprover(config, runtime) : nil let httpGateway = dependencies.makeHTTPGateway( @@ -333,7 +351,8 @@ extension XcodeMCPProxyServer { httpGateway: httpGateway, runtime: runtime, autoApprover: autoApprover, - endpoint: endpoint + endpoint: endpoint, + xcodeMode: modeResolution.xcodeMode ) } catch { autoApprover?.cancel() @@ -343,6 +362,20 @@ extension XcodeMCPProxyServer { } } + private static func logModeDiagnostic( + _ diagnostic: XcodeConnectionModeResolver.Diagnostic?, + logger: Logger + ) { + switch diagnostic { + case .notice(let message): + logger.notice("\(message)") + case .warning(let message): + logger.warning("\(message)") + case nil: + break + } + } + private static func writeDiscovery( _ policy: XcodeMCPProxyServerConfiguration.Discovery, resolvedHost: String, diff --git a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift index 0ca57910..0c1116c1 100644 --- a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift +++ b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift @@ -11,6 +11,19 @@ import XcodeMCPProxyRuntime /// `XcodeMCPProxyKit`. Lower-level parser, discovery, filesystem, and /// session-routing types stay internal to the targets that own them. public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { + /// Policy for selecting GUI Xcode routing or Xcode's headless MCP service. + public enum XcodeMode: String, Equatable, Sendable { + /// Use headless Xcode MCP when the selected Xcode supports it and it is + /// enabled; otherwise preserve GUI Xcode routing. + case automatic + + /// Route through running GUI Xcode processes. + case gui + + /// Require Xcode's headless MCP service. + case headless + } + /// Address that the Streamable HTTP server binds. public struct BindAddress: Equatable, Sendable { /// Hostname or IP address for the server socket. @@ -34,6 +47,10 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { } /// Upstream `mcpbridge` process policy. + /// + /// `processesPerXcode` is the number of process-bound bridges for each GUI + /// Xcode process. Headless and custom unbound routing use the same value as + /// the total bridge-pool size. public enum Upstream: Equatable, Sendable { /// Use Xcode's default `xcrun mcpbridge` invocation. case defaultMCPBridge(processesPerXcode: Int = 1, sessionID: String? = nil) @@ -231,6 +248,9 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { /// Optional proxy feature policy. public var featurePolicy: FeaturePolicy + /// Policy used to select GUI or headless Xcode MCP routing at startup. + public var xcodeMode: XcodeMode + /// Creates a public proxy server configuration. /// /// - Parameters: @@ -244,6 +264,7 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { /// - featurePolicy: Optional proxy feature policy. /// - toolPolicy: Explicit tool visibility policy. /// - initializeHandshake: Explicit upstream initialize handshake override. + /// - xcodeMode: GUI/headless selection policy for the stock upstream. public init( bindAddress: BindAddress = .localhost(), upstream: Upstream = .defaultMCPBridge(), @@ -254,7 +275,8 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { initializeHandshake: InitializeHandshake? = nil, discovery: Discovery = .defaultLocation, approvalPolicy: ApprovalPolicy = .manual, - featurePolicy: FeaturePolicy = .default + featurePolicy: FeaturePolicy = .default, + xcodeMode: XcodeMode = .automatic ) { self.bindAddress = bindAddress self.upstream = upstream @@ -266,6 +288,7 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { self.discovery = discovery self.approvalPolicy = approvalPolicy self.featurePolicy = featurePolicy + self.xcodeMode = xcodeMode } init(serverProxyConfig proxyConfig: ProxyConfig) { @@ -274,12 +297,17 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { host: proxyConfig.listenHost, port: proxyConfig.listenPort ), - upstream: .custom( - command: proxyConfig.upstreamCommand, - arguments: proxyConfig.upstreamArgs, - processesPerXcode: proxyConfig.upstreamProcessCount, - sessionID: proxyConfig.upstreamSessionID - ), + upstream: proxyConfig.upstreamKind == .stockMCPBridge + ? .defaultMCPBridge( + processesPerXcode: proxyConfig.upstreamProcessCount, + sessionID: proxyConfig.upstreamSessionID + ) + : .custom( + command: proxyConfig.upstreamCommand, + arguments: proxyConfig.upstreamArgs, + processesPerXcode: proxyConfig.upstreamProcessCount, + sessionID: proxyConfig.upstreamSessionID + ), maxBodyBytes: proxyConfig.maxBodyBytes, requestTimeout: proxyConfig.requestTimeout > 0 ? .seconds(proxyConfig.requestTimeout) @@ -292,7 +320,8 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { featurePolicy: FeaturePolicy( prewarmToolsList: proxyConfig.prewarmToolsList, refreshCodeIssuesMode: RefreshCodeIssuesMode(proxyConfig.refreshCodeIssuesMode) - ) + ), + xcodeMode: XcodeMode(proxyConfig.xcodeMode) ) } @@ -308,6 +337,15 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { var refreshCodeIssuesMode: RefreshCodeIssuesMode { featurePolicy.refreshCodeIssuesMode } + + var upstreamKind: ProxyConfig.UpstreamKind { + switch upstream { + case .defaultMCPBridge: + return .stockMCPBridge + case .custom: + return .custom + } + } } /// Embeddable Streamable HTTP proxy server for Xcode MCP. @@ -476,6 +514,8 @@ public final class XcodeMCPProxyServer: Sendable { var processID: @Sendable () -> Int var loadFileConfiguration: @Sendable (URL) throws -> ProxyConfig.File.LoadedConfiguration + var headlessMCPAvailability: + @Sendable () async throws -> XcodeMCPServerAvailability var makeAutoApprover: @Sendable (ProxyConfig, any ProxyRuntimeServing) -> any ProxyServerPermissionDialogAutoApprover var makeRuntime: @Sendable (ProxyRuntimeConfiguration) -> any ProxyRuntimeServing @@ -496,6 +536,10 @@ public final class XcodeMCPProxyServer: Sendable { ProxyConfig.File.LoadedConfiguration = { try ProxyConfig.File.Loader.loadStrict(configURL: $0) }, + headlessMCPAvailability: @escaping @Sendable () async throws -> + XcodeMCPServerAvailability = { + .unavailable + }, makeAutoApprover: @escaping @Sendable ( ProxyConfig, any ProxyRuntimeServing @@ -517,6 +561,7 @@ public final class XcodeMCPProxyServer: Sendable { self.executableLookupClient = executableLookupClient self.processID = processID self.loadFileConfiguration = loadFileConfiguration + self.headlessMCPAvailability = headlessMCPAvailability self.makeAutoApprover = makeAutoApprover self.makeRuntime = makeRuntime self.makeHTTPGateway = makeHTTPGateway @@ -524,8 +569,12 @@ public final class XcodeMCPProxyServer: Sendable { static var live: Self { let executableLookupClient = ExecutableLookupClient.liveValue + let statusClient = XcodeMCPServerStatusClient() return Self( executableLookupClient: executableLookupClient, + headlessMCPAvailability: { + try await statusClient.availability() + }, makeAutoApprover: { config, runtime in let additionalCandidates = XcodeMCPProxyServer.additionalPermissionDialogExecutableCandidates( config: config, @@ -663,13 +712,15 @@ public final class XcodeMCPProxyServer: Sendable { displayHost: String, port: Int, config: ProxyConfig, + xcodeMode: ProxyRuntimeConfiguration.XcodeMode, xcodeTargets: [ProxyRuntimeInventorySnapshot.XcodeTarget] ) -> String { + let runtimeConfiguration = config.runtimeConfiguration(xcodeMode: xcodeMode) let upstreamsPerXcode = max(1, min(config.upstreamProcessCount, 10)) let processRoutingActive = xcodeTargets.isEmpty == false && ProxyRuntime.supportsProcessBoundRouting( - configuration: config.runtimeConfiguration + configuration: runtimeConfiguration ) let upstreamProcessCount = processRoutingActive @@ -681,7 +732,7 @@ public final class XcodeMCPProxyServer: Sendable { "Server", " URL: http://\(displayHost):\(port)/mcp", " Upstream processes: \(upstreamProcessCount)", - " Auto approve: \(config.autoApproveXcodeDialog ? "enabled" : "disabled")", + " Auto approve: \(runtimeConfiguration.usesPermissionDialogAutomation ? "enabled" : "disabled")", "", "Xcode", ] @@ -692,6 +743,23 @@ public final class XcodeMCPProxyServer: Sendable { ) } + if xcodeMode == .headless { + lines.append(" Mode: headless") + lines.append(" Status: Xcode Service") + } else { + appendGUIXcodeStatus(xcodeTargets, to: &lines) + } + + lines.append( + " DocumentationSearch: \(documentationSearchStartupStatus(config: runtimeConfiguration))" + ) + return lines.joined(separator: "\n") + } + + private static func appendGUIXcodeStatus( + _ xcodeTargets: [ProxyRuntimeInventorySnapshot.XcodeTarget], + to lines: inout [String] + ) { switch xcodeTargets.count { case 0: lines.append(" Status: not detected") @@ -708,15 +776,16 @@ public final class XcodeMCPProxyServer: Sendable { } } - lines.append( - " DocumentationSearch: \(documentationSearchStartupStatus(config: config))" - ) - return lines.joined(separator: "\n") } - private static func documentationSearchStartupStatus(config: ProxyConfig) -> String { + private static func documentationSearchStartupStatus( + config: ProxyRuntimeConfiguration + ) -> String { + if config.xcodeMode == .headless { + return "upstream" + } if ProxyRuntime.documentationSearchIsConfigured( - configuration: config.runtimeConfiguration + configuration: config ) { return "pending" } @@ -800,6 +869,8 @@ extension ProxyConfig { upstreamArgs: config.upstreamArguments, upstreamProcessCount: config.upstreamProcessCount, upstreamSessionID: config.upstreamSessionID, + upstreamKind: config.upstreamKind, + xcodeMode: ProxyConfig.XcodeMode(config.xcodeMode), maxBodyBytes: config.maxBodyBytes, requestTimeout: requestTimeout, configPath: config.configPath, @@ -819,6 +890,7 @@ extension ProxyConfig { ProxyConfig.File.InitializeHandshakeOverride(initializeHandshake) ) } + try resolved.validateXcodeModeConfiguration() return resolved } } @@ -866,6 +938,32 @@ private extension ProxyConfig.RefreshCodeIssuesMode { } } +private extension ProxyConfig.XcodeMode { + init(_ mode: XcodeMCPProxyServerConfiguration.XcodeMode) { + switch mode { + case .automatic: + self = .automatic + case .gui: + self = .gui + case .headless: + self = .headless + } + } +} + +private extension XcodeMCPProxyServerConfiguration.XcodeMode { + init(_ mode: ProxyConfig.XcodeMode) { + switch mode { + case .automatic: + self = .automatic + case .gui: + self = .gui + case .headless: + self = .headless + } + } +} + private extension XcodeMCPProxyServerConfiguration.RefreshCodeIssuesMode { init(_ mode: ProxyConfig.RefreshCodeIssuesMode) { switch mode { diff --git a/Sources/XcodeMCPProxyRuntime/RuntimeCore/BridgeRuntime/MCPBridgeRuntime.swift b/Sources/XcodeMCPProxyRuntime/RuntimeCore/BridgeRuntime/MCPBridgeRuntime.swift index 9b4c07ca..8e1c0058 100644 --- a/Sources/XcodeMCPProxyRuntime/RuntimeCore/BridgeRuntime/MCPBridgeRuntime.swift +++ b/Sources/XcodeMCPProxyRuntime/RuntimeCore/BridgeRuntime/MCPBridgeRuntime.swift @@ -9,6 +9,7 @@ enum MCPBridgeRuntime { let sharedSessionID: String? let maxBodyBytes: Int let processBoundRoutingSupported: Bool + let removesInheritedXcodeProcessBinding: Bool init( upstreamCommand: String, @@ -16,7 +17,8 @@ enum MCPBridgeRuntime { upstreamProcessCount: Int, sharedSessionID: String?, maxBodyBytes: Int, - processBoundRoutingSupported: Bool + processBoundRoutingSupported: Bool, + removesInheritedXcodeProcessBinding: Bool = false ) { self.upstreamCommand = upstreamCommand self.upstreamArgs = upstreamArgs @@ -24,6 +26,7 @@ enum MCPBridgeRuntime { self.sharedSessionID = sharedSessionID self.maxBodyBytes = maxBodyBytes self.processBoundRoutingSupported = processBoundRoutingSupported + self.removesInheritedXcodeProcessBinding = removesInheritedXcodeProcessBinding } } @@ -145,6 +148,9 @@ enum MCPBridgeRuntime { ) -> UpstreamProcess.Config { var environment = baseEnvironment environment.removeValue(forKey: "XCODE_PID") + if config.removesInheritedXcodeProcessBinding { + environment.removeValue(forKey: "MCP_XCODE_PID") + } let sharedSessionID = config.sharedSessionID if let sharedSessionID, !sharedSessionID.isEmpty { environment["MCP_XCODE_SESSION_ID"] = sharedSessionID diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+ForwardExecutor.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+ForwardExecutor.swift index 485f25c8..4a06db66 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+ForwardExecutor.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+ForwardExecutor.swift @@ -202,6 +202,11 @@ extension ClientMCPRequestExecutor { let responseID = started.transform.responseID switch resolution { case .success(let responseData): + self.sessionManager.recordDeviceInteractionAffinityIfNeeded( + requestData: bodyData, + responseData: responseData, + operationLease: started.operationLease + ) cancellationHandle?.markCompleted() self.sessionManager.completeRequestLease(leaseID) self.logFinishedRequest( diff --git a/Sources/XcodeMCPProxyRuntime/Session/Configuration/ProxyRuntimeConfiguration.swift b/Sources/XcodeMCPProxyRuntime/Session/Configuration/ProxyRuntimeConfiguration.swift index 127eb366..7d61ae18 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Configuration/ProxyRuntimeConfiguration.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Configuration/ProxyRuntimeConfiguration.swift @@ -2,6 +2,12 @@ import Foundation import XcodeMCPKit package struct ProxyRuntimeConfiguration: Sendable { + package enum XcodeMode: String, Sendable { + case gui + case headless + case custom + } + package enum RefreshCodeIssuesMode: String, Sendable { case proxy case upstream @@ -60,6 +66,7 @@ package struct ProxyRuntimeConfiguration: Sendable { } } + package var xcodeMode: XcodeMode package var upstreamCommand: String package var upstreamArgs: [String] package var upstreamProcessCount: Int @@ -73,6 +80,7 @@ package struct ProxyRuntimeConfiguration: Sendable { package var initializeParamsOverride: InitializeHandshakeOverride? package init( + xcodeMode: XcodeMode = .gui, upstreamCommand: String, upstreamArgs: [String], upstreamProcessCount: Int = 1, @@ -85,6 +93,7 @@ package struct ProxyRuntimeConfiguration: Sendable { disabledToolNames: Set = [], initializeParamsOverride: InitializeHandshakeOverride? = nil ) { + self.xcodeMode = xcodeMode self.upstreamCommand = upstreamCommand self.upstreamArgs = upstreamArgs self.upstreamProcessCount = upstreamProcessCount diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/MCPBridgeRuntimeConfiguration+ProxyRuntimeConfiguration.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/MCPBridgeRuntimeConfiguration+ProxyRuntimeConfiguration.swift index 0a086a23..3ef7d4ba 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/MCPBridgeRuntimeConfiguration+ProxyRuntimeConfiguration.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/MCPBridgeRuntimeConfiguration+ProxyRuntimeConfiguration.swift @@ -14,9 +14,9 @@ extension MCPBridgeRuntime.Configuration { upstreamProcessCount: max(1, min(config.upstreamProcessCount, 10)), sharedSessionID: config.upstreamSessionID, maxBodyBytes: config.maxMessageBytes, - processBoundRoutingSupported: XcrunArguments.isDefaultMCPBridgeInvocation( - config: config - ) + processBoundRoutingSupported: config.xcodeMode == .gui + && XcrunArguments.isDefaultMCPBridgeInvocation(config: config), + removesInheritedXcodeProcessBinding: config.xcodeMode == .headless ) } } diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ProxyRuntimeAPI.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ProxyRuntimeAPI.swift index 58ea9dac..666dcda1 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ProxyRuntimeAPI.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ProxyRuntimeAPI.swift @@ -281,7 +281,8 @@ package final class ProxyRuntimeRequestOperation: ProxyRuntimeRequestOperating, package final class ProxyRuntime: ProxyRuntimeServing, Sendable { package static func supportsProcessBoundRouting(configuration: ProxyRuntimeConfiguration) -> Bool { - XcrunArguments.isDefaultMCPBridgeInvocation(config: configuration) + configuration.xcodeMode == .gui + && XcrunArguments.isDefaultMCPBridgeInvocation(config: configuration) } package static func documentationSearchIsConfigured(configuration: ProxyRuntimeConfiguration) -> Bool { @@ -354,15 +355,18 @@ package final class ProxyRuntime: ProxyRuntimeServing, Sendable { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let eventLoop = group.next() let eventSource = ProxyRuntimeEventSource() - let processEventMonitor = XcodeProcessEventMonitor() + let processEventMonitor: XcodeProcessEventMonitor? = + config.xcodeMode == .headless ? nil : XcodeProcessEventMonitor() let coordinator = RuntimeCoordinator( config: config, eventLoop: eventLoop, - upstreamReadinessGate: .liveDefault( - config: config, - clock: .liveValue, - processEventMonitor: processEventMonitor - ), + upstreamReadinessGate: processEventMonitor.map { + .liveDefault( + config: config, + clock: .liveValue, + processEventMonitor: $0 + ) + } ?? .alwaysReady(), xcodeTargetDiscovery: processEventMonitor, xcodeProcessEventMonitor: processEventMonitor, notificationSink: { sessionID, data in diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+UpstreamRouting.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+UpstreamRouting.swift index 2a6c5370..d4c7dce1 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+UpstreamRouting.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+UpstreamRouting.swift @@ -528,6 +528,18 @@ extension RuntimeCoordinator { markRequestSucceeded(operationLease) } + private func validatesForwardingAdmission( + _ admission: RouteForwardingAdmission?, + operationLease: UpstreamOperationLease + ) -> Bool { + guard let admission else { return true } + guard admission.proof(for: operationLease.upstreamIndex) == operationLease.proof else { + return false + } + guard let route = admission.route else { return true } + return processControlPlane.validate(route) + } + @discardableResult func sendUpstream( _ data: Data, @@ -561,13 +573,13 @@ extension RuntimeCoordinator { requestSendCompletion?.complete(.notSent) return false } - if let admission { - guard processControlPlane.validate(admission.route), - admission.proof(for: upstreamIndex) == operationLease.proof else { - onRejected() - requestSendCompletion?.complete(.notSent) - return false - } + guard validatesForwardingAdmission( + admission, + operationLease: operationLease + ) else { + onRejected() + requestSendCompletion?.complete(.notSent) + return false } var scheduled = false guard initializeManager.performIfRunning({ @@ -590,13 +602,13 @@ extension RuntimeCoordinator { onRejected() return } - if let admission { - guard self.processControlPlane.validate(admission.route), - admission.proof(for: upstreamIndex) == operationLease.proof else { - requestSendCompletion?.complete(.notSent) - onRejected() - return - } + guard self.validatesForwardingAdmission( + admission, + operationLease: operationLease + ) else { + requestSendCompletion?.complete(.notSent) + onRejected() + return } let result = await operationLease.slot.send(data) requestSendCompletion?.complete( diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift index e8c0925b..49ea926a 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift @@ -488,13 +488,19 @@ extension RuntimeCoordinator { } func immediateToolRoutingDecision(for requestJSON: Any) -> ToolRoutingDecision? { - guard processRoutingEnabled else { - return .forward(preferredUpstreamIndex: nil) - } guard let object = requestJSON as? [String: Any], let request = toolRoutingRequest(in: object) else { return .forward(preferredUpstreamIndex: nil) } + if let affinityDecision = deviceInteractionAffinityRoutingDecision( + for: object, + request: request + ) { + return affinityDecision + } + guard processRoutingEnabled else { + return .forward(preferredUpstreamIndex: nil) + } if request.id != nil, request.toolName == "XcodeListWindows" { return .localXcodeListWindows } @@ -509,6 +515,158 @@ extension RuntimeCoordinator { return nil } + func recordDeviceInteractionAffinityIfNeeded( + requestData: Data, + responseData: Data, + operationLease: UpstreamOperationLease + ) { + guard let call = DeviceInteractionToolCall.decode(requestData: requestData) else { + return + } + + switch call { + case .startsSession: + guard let key = DeviceInteractionToolCall.successfulSessionKey( + from: responseData + ) else { + return + } + let routeID: ProcessRouteID? + if processRoutingEnabled { + guard let route = xcodeProcessRoute( + forUpstreamIndex: operationLease.upstreamIndex + ), + let routeProof = processControlPlane.routeProof(routeID: route.id) + else { + return + } + routeID = routeProof.routeID + } else { + routeID = nil + } + upstreamTopologyCommitLock.withLock { + guard upstreamTopology.validate(operationLease) else { return } + deviceInteractionAffinityAuthority.record( + .init( + upstreamProof: operationLease.proof, + routeID: routeID + ), + for: key + ) + } + case .continuesSession(let key, let endsSession): + guard endsSession, + DeviceInteractionToolCall.isSuccessfulResponse(responseData) else { + return + } + deviceInteractionAffinityAuthority.remove(key: key) + } + } + + private func deviceInteractionAffinityRoutingDecision( + for requestObject: [String: Any], + request: ToolRoutingRequest + ) -> ToolRoutingDecision? { + guard case .continuesSession(let key, _) = DeviceInteractionToolCall.decode( + requestObject + ) else { + return nil + } + guard let affinity = deviceInteractionAffinityAuthority.affinity(for: key) else { + if processRoutingEnabled == false, + upstreamTopology.snapshot().entries.count == 1 + { + return nil + } + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "unknown device interaction session" + ) + ) + } + guard upstreamTopology.validate(affinity.upstreamProof) else { + deviceInteractionAffinityAuthority.remove(key: key) + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "device interaction session is no longer available" + ) + ) + } + guard let affinityRouteID = affinity.routeID else { + guard processRoutingEnabled == false else { + deviceInteractionAffinityAuthority.remove(key: key) + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "device interaction session is no longer available" + ) + ) + } + return .forwardAdmitted( + preferredUpstreamIndices: [affinity.upstreamProof.slotID.rawValue], + admission: RouteForwardingAdmission( + upstreamProofs: [affinity.upstreamProof] + ) + ) + } + guard processRoutingEnabled, + let routeProof = processControlPlane.routeProof(routeID: affinityRouteID), + let routeAdmission = processControlPlane.admit(routeProof) else { + deviceInteractionAffinityAuthority.remove(key: key) + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "device interaction session is no longer available" + ) + ) + } + let windowAdmission: WindowRouteAdmission? + if hasOwnerHint(request) { + let owners = windowOwnershipAuthority.snapshot() + guard case .resolved(let processID, _, let windowProof) = cachedOwnerResolution( + for: request + ), + processID == affinityRouteID.processID, + windowProof.route.routeID == affinityRouteID, + windowProof.windowEpoch == owners.epoch else { + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "device interaction session does not own the selected Xcode window" + ) + ) + } + windowAdmission = WindowRouteAdmission( + proof: windowProof, + route: routeAdmission, + rewritePlan: ownerBoundRequestRewritePlan( + processID: processID, + request: request, + owners: owners + ) + ) + } else { + windowAdmission = nil + } + return .forwardAdmitted( + preferredUpstreamIndices: [affinity.upstreamProof.slotID.rawValue], + admission: RouteForwardingAdmission( + route: routeAdmission, + upstreamProofs: [affinity.upstreamProof], + window: windowAdmission + ) + ) + } + + private func deviceInteractionRoutingErrors( + id: JSONRPC.ID?, + message: String + ) -> [ToolRoutingError] { + id.map { [ToolRoutingError(id: $0, message: message)] } ?? [] + } + private func ownerBoundToolRoutingDecision( for requestJSON: Any, requestTimeoutOverride: TimeAmount? diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift index 69eb88b1..496418f5 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift @@ -223,6 +223,11 @@ protocol RuntimeToolRoutingPort: Sendable { route: ControlPlane.Route, requestTimeoutOverride: TimeAmount? ) async throws -> JSONValue + func recordDeviceInteractionAffinityIfNeeded( + requestData: Data, + responseData: Data, + operationLease: UpstreamOperationLease + ) } protocol RuntimeUpstreamForwardingPort: Sendable { @@ -415,6 +420,12 @@ extension RuntimeToolRoutingPort { func primaryUpstreamIndex(forXcodeProcessID _: pid_t) -> Int? { nil } + + func recordDeviceInteractionAffinityIfNeeded( + requestData _: Data, + responseData _: Data, + operationLease _: UpstreamOperationLease + ) {} } extension RuntimeUpstreamForwardingPort { @@ -544,6 +555,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } let windowOwnershipAuthority = WindowOwnershipAuthority() let windowRoutingResolver = WindowRoutingResolver() + let deviceInteractionAffinityAuthority = DeviceInteractionAffinityAuthority() let prewarmDocumentationProviderOnStartup: Bool let testHooks: RuntimeCoordinatorTestHooks private let lifecycleStartedBox = NIOLockedValueBox(false) @@ -662,6 +674,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { config: ProxyRuntimeConfiguration ) -> Bool { config.disabledToolNames.contains(DocumentationProvider.ToolCatalog.toolName) == false + && config.xcodeMode == .gui && MCPBridgeRuntime.supportsProcessBoundRouting( config: config.mcpBridgeRuntimeConfiguration ) @@ -1086,6 +1099,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } func debugReset() { + deviceInteractionAffinityAuthority.clear() let initializeReset = initializeManager.resetForDebug() initializeReset.timeout?.cancel() initializeReset.recoveryTimeout?.cancel() @@ -1118,6 +1132,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } func shutdown() async { + deviceInteractionAffinityAuthority.clear() let shutdownState = initializeManager.beginShutdown() let pendingInitializes = shutdownState.pending for pending in pendingInitializes { @@ -1173,6 +1188,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } func cancelForDeinit() { + deviceInteractionAffinityAuthority.clear() let shutdownState = initializeManager.beginShutdown() shutdownState.timeout?.cancel() shutdownState.recoveryTimeout?.cancel() @@ -1207,6 +1223,9 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { func applyProcessControlPlaneTransition( _ transition: ProcessControlPlaneTransition ) -> [ControlPlane.RPCCancellationDelivery] { + deviceInteractionAffinityAuthority.remove( + routeIDs: Set(transition.retiredRoutes.map(\.id)) + ) var cancellationDeliveries: [ControlPlane.RPCCancellationDelivery] = [] for effect in transition.effects { switch effect { @@ -1240,6 +1259,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { upstreamTopologyCommitLock.withLock { let transition = mutation() publishUpstreamTopology(transition.snapshot) + removeDeviceInteractionAffinities(in: transition) return transition } } @@ -1250,10 +1270,21 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { upstreamTopologyCommitLock.withLock { guard let transition = mutation() else { return nil } publishUpstreamTopology(transition.snapshot) + removeDeviceInteractionAffinities(in: transition) return transition } } + private func removeDeviceInteractionAffinities( + in transition: UpstreamTopologyAuthority.Transition + ) { + var proofs = Set(transition.retired.map(\.operationLease.proof)) + if let replaced = transition.replaced { + proofs.insert(replaced.operationLease.proof) + } + deviceInteractionAffinityAuthority.remove(upstreamProofs: proofs) + } + func processToolCatalogExposedProcessIDs() -> Set { catalogExposedUsableProcessIDs() } diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift index 24291d16..1f5f8887 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift @@ -24,10 +24,16 @@ enum ToolRoutingDecision: Sendable { } struct RouteForwardingAdmission: Sendable { - let route: ProcessControlPlaneAuthority.RouteAdmissionLease + let route: ProcessControlPlaneAuthority.RouteAdmissionLease? let upstreamProofs: [UpstreamTopologyProof] let window: WindowRouteAdmission? + init(upstreamProofs: [UpstreamTopologyProof]) { + self.route = nil + self.upstreamProofs = upstreamProofs + self.window = nil + } + init( route: ProcessControlPlaneAuthority.RouteAdmissionLease, upstreamProofs: [UpstreamTopologyProof], diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/XcodeUpstreamReadiness.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/XcodeUpstreamReadiness.swift index a80eaf0c..dcd8a0af 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/XcodeUpstreamReadiness.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/XcodeUpstreamReadiness.swift @@ -10,7 +10,9 @@ extension UpstreamReadinessGate { clock: ClockClient, processEventMonitor: any XcodeProcessEventMonitoring ) -> UpstreamReadinessGate { - guard XcrunArguments.isDefaultMCPBridgeInvocation(config: config) else { + guard config.xcodeMode == .gui, + XcrunArguments.isDefaultMCPBridgeInvocation(config: config) + else { return .alwaysReady() } diff --git a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift new file mode 100644 index 00000000..907733b2 --- /dev/null +++ b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift @@ -0,0 +1,143 @@ +import Foundation +import NIOConcurrencyHelpers +import XcodeMCPKit + +enum DeviceInteractionToolCall: Equatable, Sendable { + case startsSession + case continuesSession(key: String, endsSession: Bool) + + static func decode(_ requestJSON: Any) -> Self? { + guard let object = requestJSON as? [String: Any], + JSONRPC.Message.Inspector.method(from: object) == "tools/call", + let params = object["params"] as? [String: Any], + let toolName = params["name"] as? String else { + return nil + } + + switch toolName { + case "DeviceInteractionStartSession", "DeviceInteractionStartWorkspaceSession": + return .startsSession + case "DeviceInteractionSynthesize": + return continuation( + arguments: params["arguments"], + keyName: "interactSessionKey", + endsSession: false + ) + case "DeviceInteractionInstallAndRun": + return continuation( + arguments: params["arguments"], + keyName: "interactionSessionKey", + endsSession: false + ) + case "DeviceInteractionEndSession": + return continuation( + arguments: params["arguments"], + keyName: "interactionSessionKey", + endsSession: true + ) + default: + return nil + } + } + + static func decode(requestData: Data) -> Self? { + guard let object = try? JSONRPC.Wire.object(fromData: requestData) else { + return nil + } + return decode(object) + } + + static func successfulSessionKey(from responseData: Data) -> String? { + guard let result = successfulResult(from: responseData), + let structuredContent = result["structuredContent"] as? [String: Any], + let key = structuredContent["interactionSessionKey"] as? String, + key.isEmpty == false else { + return nil + } + return key + } + + static func isSuccessfulResponse(_ responseData: Data) -> Bool { + successfulResult(from: responseData) != nil + } + + private static func continuation( + arguments: Any?, + keyName: String, + endsSession: Bool + ) -> Self? { + guard let arguments = arguments as? [String: Any], + let key = arguments[keyName] as? String, + key.isEmpty == false else { + return nil + } + return .continuesSession(key: key, endsSession: endsSession) + } + + private static func successfulResult(from responseData: Data) -> [String: Any]? { + guard let object = try? JSONRPC.Wire.object(fromData: responseData), + object["error"] == nil, + let result = object["result"] as? [String: Any], + result["isError"] as? Bool != true else { + return nil + } + return result + } +} + +final class DeviceInteractionAffinityAuthority: Sendable { + struct Affinity: Equatable, Sendable { + let upstreamProof: UpstreamTopologyProof + let routeID: ProcessRouteID? + + init( + upstreamProof: UpstreamTopologyProof, + routeID: ProcessRouteID? = nil + ) { + self.upstreamProof = upstreamProof + self.routeID = routeID + } + } + + private let affinities = NIOLockedValueBox<[String: Affinity]>([:]) + + func affinity(for key: String) -> Affinity? { + affinities.withLockedValue { $0[key] } + } + + func record(_ affinity: Affinity, for key: String) { + guard key.isEmpty == false else { return } + affinities.withLockedValue { $0[key] = affinity } + } + + func remove(key: String) { + _ = affinities.withLockedValue { $0.removeValue(forKey: key) } + } + + func remove(routeIDs: Set) { + guard routeIDs.isEmpty == false else { return } + affinities.withLockedValue { affinities in + affinities = affinities.filter { _, affinity in + guard let routeID = affinity.routeID else { return true } + return routeIDs.contains(routeID) == false + } + } + } + + func remove(upstreamProofs: Set) { + guard upstreamProofs.isEmpty == false else { return } + affinities.withLockedValue { affinities in + affinities = affinities.filter { _, affinity in + upstreamProofs.contains(affinity.upstreamProof) == false + } + } + } + + func clear() { + affinities.withLockedValue { $0.removeAll() } + } + + func count() -> Int { + affinities.withLockedValue(\.count) + } +} diff --git a/Sources/XcodeMCPProxyToolVerifier/README.md b/Sources/XcodeMCPProxyToolVerifier/README.md index d8657f57..514567e8 100644 --- a/Sources/XcodeMCPProxyToolVerifier/README.md +++ b/Sources/XcodeMCPProxyToolVerifier/README.md @@ -2,23 +2,47 @@ Live verifier for `xcode-mcp-proxy-server`. +GUI Xcode is the default and preserves the existing verifier workflow: + ```sh swift run xcode-mcp-proxy-tool-verifier ``` +To verify Xcode 27's headless service without opening an Xcode window: + +```sh +swift run xcode-mcp-proxy-tool-verifier --xcode-mode headless --upstream-processes 2 +``` + +Headless access must already be enabled. The verifier does not run +`mcp-server enable`, approve an agent or folder, change permission policy, or +stop the process-shared Xcode Service. Its `XcodeOpenWorkspace` call is the +agent and folder approval bootstrap; complete any approval requested by Xcode, +then let the call finish. + The verifier: - builds the local debug `xcode-mcp-proxy-server` - starts it on a verifier-only port -- opens `XcodeMCPKit.xcworkspace` +- in GUI mode, opens `XcodeMCPKit.xcworkspace` in Xcode +- in headless mode, starts the proxy with `--xcode-mode headless` and calls + `XcodeOpenWorkspace` through the proxy +- detects `XcodeListWindows` versus `XcodeListWorkspaces` from `tools/list` + and uses the advertised `tabIdentifier` or `workspaceIdentifier` schema - uses the tracked fixture project in `Fixtures/ProxyToolVerifierFixture` -- reads the live `tools/list` catalog -- calls each available tool one at a time +- reads and records the complete live `tools/list` catalog +- calls each tool with a fixture-safe plan one at a time; unknown tools and + tools without safe arguments remain in the report as `not-planned` +- records raw progress notification fields for build and test operations +- closes only the headless workspace identifier returned by its own + `XcodeOpenWorkspace` call, including when later verification fails - writes `ProxyToolVerifierOutput/report.json` - prints the tested tool list at the end Logs, cache files, and reports are written under `ProxyToolVerifierOutput/`, -which is ignored by git. +which is ignored by git. Headless runs also write +`ProxyToolVerifierOutput/headless-tool-catalog.json`, preserving every raw tool +descriptor for comparison with later Xcode previews. `Fixtures/ProxyToolVerifierFixture` is a small tracked Xcode project used only by this verifier. It gives the live tools a stable app, scheme, test target, @@ -29,5 +53,10 @@ Options: ```sh swift run xcode-mcp-proxy-tool-verifier --port 18765 --request-timeout 600 +swift run xcode-mcp-proxy-tool-verifier --xcode-mode headless --request-timeout 600 swift run xcode-mcp-proxy-tool-verifier --keep-server ``` + +`--upstream-processes` applies to both modes so the headless verifier also +exercises pooled-upstream routing. `--no-open-xcode` applies only to GUI +verification. diff --git a/Sources/XcodeMCPProxyToolVerifier/main.swift b/Sources/XcodeMCPProxyToolVerifier/main.swift index f66f2daf..76676a99 100644 --- a/Sources/XcodeMCPProxyToolVerifier/main.swift +++ b/Sources/XcodeMCPProxyToolVerifier/main.swift @@ -22,6 +22,7 @@ private struct VerifierOptions { var upstreamProcesses = 2 var requestTimeoutSeconds = 600 var outputDirectory = URL(fileURLWithPath: "ProxyToolVerifierOutput", isDirectory: true) + var xcodeMode: VerifierXcodeMode = .gui var keepServer = false var noOpenXcode = false var verbose = false @@ -44,6 +45,12 @@ private struct VerifierOptions { ?? Self.fail("--request-timeout requires an integer") case "--output": outputDirectory = URL(fileURLWithPath: try Self.value(after: argument, in: arguments, index: &index), isDirectory: true) + case "--xcode-mode": + let value = try Self.value(after: argument, in: arguments, index: &index) + guard let mode = VerifierXcodeMode(rawValue: value) else { + throw VerifierFailure("--xcode-mode must be gui or headless") + } + xcodeMode = mode case "--keep-server": keepServer = true case "--no-open-xcode": @@ -71,11 +78,12 @@ private struct VerifierOptions { Options: --host host Listen host for the debug proxy server. Default: 127.0.0.1 --port port Dedicated verifier port. Default: 18765 - --upstream-processes n Upstream mcpbridge processes per Xcode. Default: 2 + --upstream-processes n Upstream mcpbridge process count. Default: 2 --request-timeout seconds XcodeMCP request timeout. Default: 600 - --output path Ignored verifier output directory. Default: ProxyToolVerifierOutput + --output path Git-ignored verifier output directory. Default: ProxyToolVerifierOutput + --xcode-mode gui|headless Xcode runtime to verify. Default: gui --keep-server Leave the debug proxy server running. - --no-open-xcode Do not open the fixture package in Xcode. + --no-open-xcode Do not open the fixture workspace in GUI Xcode. -v, --verbose Print tool arguments and response summaries. """ @@ -93,6 +101,11 @@ private struct VerifierOptions { } } +private enum VerifierXcodeMode: String, Codable { + case gui + case headless +} + private struct ProxyToolVerifier { let options: VerifierOptions let fileManager = FileManager.default @@ -109,7 +122,7 @@ private struct ProxyToolVerifier { try? fixtureSnapshot.restore() } - if options.noOpenXcode == false { + if options.xcodeMode == .gui, options.noOpenXcode == false { try openFixtureInXcode(fixture.rootWorkspaceURL) } @@ -141,17 +154,42 @@ private struct ProxyToolVerifier { fixture: FixtureLayout, outputRoot: URL ) async throws -> Bool { - var state = VerificationState(fixture: fixture) let tools = try await client.listTools() - state.availableTools = Set(tools.map(\.name)) + let workspaceSurface = try WorkspaceToolSurface.detect(in: tools) + guard workspaceSurface.matches(options.xcodeMode) else { + throw VerifierFailure( + "proxy started in \(options.xcodeMode.rawValue) mode but tools/list exposed " + + "\(workspaceSurface.catalogToolName)" + ) + } + var state = VerificationState( + fixture: fixture, + tools: tools, + workspaceSurface: workspaceSurface + ) var records: [ToolVerificationRecord] = [] let reportURL = outputRoot.appendingPathComponent("report.json") + if workspaceSurface == .headless { + let catalogURL = outputRoot.appendingPathComponent("headless-tool-catalog.json") + try writeToolCatalog( + ToolCatalogArtifact( + mode: options.xcodeMode, + catalogTool: workspaceSurface.catalogToolName, + toolCount: tools.count, + tools: tools.map(\.raw) + ), + to: catalogURL + ) + } + func currentReport() -> VerificationReport { VerificationReport( endpoint: options.endpoint.absoluteString, + xcodeMode: options.xcodeMode, fixturePath: fixture.xcodeProjectURL.path, workspacePath: fixture.rootWorkspaceURL.path, + workspace: state.workspaceRecord, toolCount: tools.count, availableTools: tools.map(\.name).sorted(), toolDescriptors: tools.sorted { $0.name < $1.name }, @@ -159,40 +197,102 @@ private struct ProxyToolVerifier { ) } - let executionPlan = toolExecutionOrder(availableTools: state.availableTools) - print("Available tools: \(tools.count)") - print("Planned tools: \(executionPlan.count)") - - for (index, toolName) in executionPlan.enumerated() { - guard state.availableTools.contains(toolName) else { - records.append( - ToolVerificationRecord( - name: toolName, - status: .failed, - elapsedSeconds: 0, - detail: "missing from tools/list", - arguments: nil - ) + do { + if workspaceSurface == .headless { + let openArguments: [String: MCPJSONValue] = [ + "path": .string(fixture.rootWorkspaceURL.path), + ] + print("-> XcodeOpenWorkspace") + let openRecord = await call( + "XcodeOpenWorkspace", + arguments: openArguments, + client: client ) - continue + print("<- [\(openRecord.status.rawValue)] XcodeOpenWorkspace (\(formatSeconds(openRecord.elapsedSeconds)))") + records.append(openRecord) + try state.observe(toolName: "XcodeOpenWorkspace", record: openRecord) + try writeReport(currentReport(), to: reportURL, announce: false) } - let arguments = try state.arguments(for: toolName) - print("-> [\(index + 1)/\(executionPlan.count)] \(toolName)") - let record = await call( - toolName, - arguments: arguments, - client: client + + let executionPlan = toolExecutionOrder( + availableTools: state.availableTools, + excluding: workspaceLifecycleToolNames ) - print("<- [\(record.status.rawValue)] \(toolName) (\(formatSeconds(record.elapsedSeconds)))") - records.append(record) - try state.observe(toolName: toolName, record: record) - try writeReport(currentReport(), to: reportURL, announce: false) - } + print("Available tools: \(tools.count)") + print("Catalog entries to evaluate: \(executionPlan.count)") + + for (index, toolName) in executionPlan.enumerated() { + let decision = try state.executionDecision(for: toolName) + switch decision { + case .call(let arguments): + print("-> [\(index + 1)/\(executionPlan.count)] \(toolName)") + let record = await call( + toolName, + arguments: arguments, + client: client + ) + print("<- [\(record.status.rawValue)] \(toolName) (\(formatSeconds(record.elapsedSeconds)))") + records.append(record) + try state.observe(toolName: toolName, record: record) + case .skip(let reason): + print("-- [not-planned] \(toolName) - \(reason)") + records.append( + ToolVerificationRecord( + name: toolName, + status: .notPlanned, + elapsedSeconds: 0, + detail: reason, + arguments: nil + ) + ) + } + try writeReport(currentReport(), to: reportURL, announce: false) + } + + if let interactionSessionKey = state.openedInteractionSessionKeyForCleanup, + state.availableTools.contains("DeviceInteractionEndSession") { + let endRecord = await endDeviceInteractionSession( + interactionSessionKey: interactionSessionKey, + client: client + ) + records.append(endRecord) + state.observeDeviceInteractionEnd(record: endRecord) + } + + if let workspaceIdentifier = state.openedWorkspaceIdentifierForCleanup { + let closeRecord = await closeWorkspace( + workspaceIdentifier: workspaceIdentifier, + client: client + ) + records.append(closeRecord) + state.observeWorkspaceClose(record: closeRecord) + } - let report = currentReport() - try writeReport(report, to: reportURL) - printReport(report) - return report.hasHardFailures + let report = currentReport() + try writeReport(report, to: reportURL) + printReport(report) + return report.hasHardFailures + } catch { + if let interactionSessionKey = state.openedInteractionSessionKeyForCleanup, + state.availableTools.contains("DeviceInteractionEndSession") { + let endRecord = await endDeviceInteractionSession( + interactionSessionKey: interactionSessionKey, + client: client + ) + records.append(endRecord) + state.observeDeviceInteractionEnd(record: endRecord) + } + if let workspaceIdentifier = state.openedWorkspaceIdentifierForCleanup { + let closeRecord = await closeWorkspace( + workspaceIdentifier: workspaceIdentifier, + client: client + ) + records.append(closeRecord) + state.observeWorkspaceClose(record: closeRecord) + } + try? writeReport(currentReport(), to: reportURL, announce: false) + throw error + } } private func call( @@ -201,9 +301,23 @@ private struct ProxyToolVerifier { client: XcodeMCP ) async -> ToolVerificationRecord { let started = Date() + let progressRecorder = progressReportingToolNames.contains(toolName) + ? RawProgressRecorder() + : nil do { - let result = try await client.callTool(toolName, arguments: arguments) + let result: MCPToolResult + if let progressRecorder { + result = try await client.callTool( + toolName, + arguments: arguments + ) { progress in + await progressRecorder.append(progress.raw) + } + } else { + result = try await client.callTool(toolName, arguments: arguments) + } let elapsed = Date().timeIntervalSince(started) + let rawProgress = await progressRecorder?.snapshot() let detail = responseSummary(result) let status = verificationStatus( toolName: toolName, @@ -216,10 +330,12 @@ private struct ProxyToolVerifier { elapsedSeconds: elapsed, detail: detail, arguments: arguments, - rawResult: result.raw + rawResult: result.raw, + rawProgress: rawProgress ) } catch let error as XcodeMCPError { let elapsed = Date().timeIntervalSince(started) + let rawProgress = await progressRecorder?.snapshot() let status: ToolVerificationStatus switch error { case .requestTimedOut: @@ -235,32 +351,65 @@ private struct ProxyToolVerifier { status: status, elapsedSeconds: elapsed, detail: errorDescription(error), - arguments: arguments + arguments: arguments, + rawProgress: rawProgress ) } catch { let elapsed = Date().timeIntervalSince(started) + let rawProgress = await progressRecorder?.snapshot() return ToolVerificationRecord( name: toolName, status: .failed, elapsedSeconds: elapsed, detail: errorDescription(error), - arguments: arguments + arguments: arguments, + rawProgress: rawProgress ) } } + private func closeWorkspace( + workspaceIdentifier: String, + client: XcodeMCP + ) async -> ToolVerificationRecord { + print("-> XcodeCloseWorkspace") + let record = await call( + "XcodeCloseWorkspace", + arguments: ["workspaceIdentifier": .string(workspaceIdentifier)], + client: client + ) + print("<- [\(record.status.rawValue)] XcodeCloseWorkspace (\(formatSeconds(record.elapsedSeconds)))") + return record + } + + private func endDeviceInteractionSession( + interactionSessionKey: String, + client: XcodeMCP + ) async -> ToolVerificationRecord { + print("-> DeviceInteractionEndSession (cleanup)") + let record = await call( + "DeviceInteractionEndSession", + arguments: ["interactionSessionKey": .string(interactionSessionKey)], + client: client + ) + print("<- [\(record.status.rawValue)] DeviceInteractionEndSession (\(formatSeconds(record.elapsedSeconds)))") + return record + } + private func connectToProxy(server: RunningProcess) async throws -> XcodeMCP { + try await waitForProxyListener(server: server) + var lastError: (any Error)? - for _ in 0..<90 { + for attempt in 0..<90 { guard server.isRunning else { throw VerifierFailure( - "debug proxy server exited before becoming ready with status " + "debug proxy server exited before MCP initialization with status " + "\(server.terminationStatus)" ) } do { return try await XcodeMCP( - configuration: .init( + configuration: .init( transport: .streamableHTTP(endpoint: options.endpoint), clientName: "XcodeMCPProxyToolVerifier", clientVersion: "dev", @@ -268,15 +417,68 @@ private struct ProxyToolVerifier { ) ) } catch { + try Task.checkCancellation() + guard isRetryableMCPInitializationError(error) else { + throw error + } lastError = error - try await Task.sleep(for: .seconds(1)) + if attempt < 89 { + try await Task.sleep(for: .seconds(1)) + } } } throw VerifierFailure( - "proxy did not become ready at \(options.endpoint.absoluteString): \(lastError.map(errorDescription) ?? "unknown error")" + "proxy did not complete MCP initialization at " + + "\(options.endpoint.absoluteString): " + + "\(lastError.map(errorDescription) ?? "unknown error")" ) } + private func waitForProxyListener(server: RunningProcess) async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = false + configuration.timeoutIntervalForRequest = 1 + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + + var request = URLRequest(url: options.endpoint) + request.httpMethod = "HEAD" + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(30)) + while clock.now < deadline { + guard server.isRunning else { + throw VerifierFailure( + "debug proxy server exited before becoming ready with status " + + "\(server.terminationStatus)" + ) + } + do { + let (_, response) = try await session.data(for: request) + if response is HTTPURLResponse { + return + } + } catch { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(100)) + } + } + throw VerifierFailure( + "proxy listener did not become ready at \(options.endpoint.absoluteString)" + ) + } + + private func isRetryableMCPInitializationError(_ error: any Error) -> Bool { + guard let error = error as? XcodeMCPError else { return false } + switch error { + case .closed, .transportUnavailable: + return true + case .serverError(let code, _, _): + return code == -32_001 || code == -32_002 + case .invalidRequest, .invalidResponse, .requestTimedOut, .sessionRecoveryFailed: + return false + } + } + private func prepareOutputDirectory(_ outputRoot: URL) throws { try fileManager.createDirectory( at: outputRoot, @@ -311,13 +513,19 @@ private struct ProxyToolVerifier { let logHandle = try FileHandle(forWritingTo: logURL) let process = Process() process.executableURL = binary - process.arguments = [ + var arguments = [ "--listen", "\(options.host):\(options.port)", - "--upstream-processes", "\(options.upstreamProcesses)", "--request-timeout", "\(options.requestTimeoutSeconds)", - "--auto-approve", - "--refresh-code-issues-mode", "proxy", + "--xcode-mode", options.xcodeMode.rawValue, + "--upstream-processes", "\(options.upstreamProcesses)", ] + if options.xcodeMode == .gui { + arguments += [ + "--auto-approve", + "--refresh-code-issues-mode", "proxy", + ] + } + process.arguments = arguments var environment = ProcessInfo.processInfo.environment environment["XCODE_MCP_PROXY_CACHE_ROOT"] = outputRoot.appendingPathComponent("cache").path process.environment = environment @@ -381,6 +589,13 @@ private struct ProxyToolVerifier { } } + private func writeToolCatalog(_ catalog: ToolCatalogArtifact, to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(catalog).write(to: url, options: [.atomic]) + print("Headless tool catalog: \(url.path)") + } + private func printReport(_ report: VerificationReport) { let counts = Dictionary(grouping: report.records, by: \.status) .mapValues(\.count) @@ -397,10 +612,13 @@ private struct ProxyToolVerifier { if options.verbose, let arguments = record.arguments { print(" args: \(jsonString(arguments))") } + if options.verbose, let rawProgress = record.rawProgress { + print(" progress notifications: \(rawProgress.count)") + } } let testedTools = report.records + .filter { $0.status != .notPlanned } .map(\.name) - .filter { $0 != "tools/list" } print("") print("Tested tools (\(testedTools.count))") for name in testedTools.sorted() { @@ -409,16 +627,137 @@ private struct ProxyToolVerifier { } } +private enum WorkspaceToolSurface: String, Codable { + case gui + case headless + + var catalogToolName: String { + switch self { + case .gui: + return "XcodeListWindows" + case .headless: + return "XcodeListWorkspaces" + } + } + + func matches(_ mode: VerifierXcodeMode) -> Bool { + switch (self, mode) { + case (.gui, .gui), (.headless, .headless): + return true + default: + return false + } + } + + static func detect(in tools: [MCPTool]) throws -> WorkspaceToolSurface { + let names = Set(tools.map(\.name)) + let hasWindows = names.contains("XcodeListWindows") + let hasWorkspaces = names.contains("XcodeListWorkspaces") + switch (hasWindows, hasWorkspaces) { + case (true, false): + return .gui + case (false, true): + let missingLifecycleTools = workspaceLifecycleToolNames + .subtracting(names) + .sorted() + guard missingLifecycleTools.isEmpty else { + throw VerifierFailure( + "headless tools/list is missing workspace lifecycle tools: " + + missingLifecycleTools.joined(separator: ", ") + ) + } + return .headless + case (true, true): + throw VerifierFailure( + "tools/list exposed both XcodeListWindows and XcodeListWorkspaces; " + + "workspace ownership is ambiguous" + ) + case (false, false): + throw VerifierFailure( + "tools/list exposed neither XcodeListWindows nor XcodeListWorkspaces" + ) + } + } +} + +private enum ToolExecutionDecision { + case call([String: MCPJSONValue]) + case skip(String) +} + +private struct ToolPlanUnavailable: Error { + let reason: String +} + +private struct WorkspaceVerificationRecord: Codable { + let surface: WorkspaceToolSurface + let requestedPath: String + let returnedIdentifier: String? + let returnedPath: String? + let openedByVerifier: Bool + let closeAttempted: Bool + let closedByVerifier: Bool +} + private struct VerificationState { let fixture: FixtureLayout - var availableTools: Set = [] + let toolsByName: [String: MCPTool] + let workspaceSurface: WorkspaceToolSurface + let availableTools: Set var tabIdentifier: String? + var workspaceIdentifier: String? + var workspaceReportedPath: String? + var workspaceOpenedByVerifier = false + var workspaceCloseAttempted = false + var workspaceClosedByVerifier = false var schemeName = "ProxyToolVerifierFixture" var runDestination = "iPhone 17 (27.0)" var testTargetName = "ProxyToolVerifierFixtureTests" var testIdentifier = "ProxyToolVerifierFixtureTests/testMessage()" var interactionSessionIdentifier = "Proxy Tool Verifier \(UUID().uuidString)" var interactionSessionKey = "invalid-verifier-session-key" + var interactionSessionOpenedByVerifier = false + var interactionSessionEndAttempted = false + + init( + fixture: FixtureLayout, + tools: [MCPTool], + workspaceSurface: WorkspaceToolSurface + ) { + self.fixture = fixture + self.toolsByName = tools.reduce(into: [:]) { result, tool in + result[tool.name] = tool + } + self.workspaceSurface = workspaceSurface + self.availableTools = Set(tools.map(\.name)) + } + + var workspaceRecord: WorkspaceVerificationRecord { + WorkspaceVerificationRecord( + surface: workspaceSurface, + requestedPath: fixture.rootWorkspaceURL.path, + returnedIdentifier: workspaceIdentifier, + returnedPath: workspaceReportedPath, + openedByVerifier: workspaceOpenedByVerifier, + closeAttempted: workspaceCloseAttempted, + closedByVerifier: workspaceClosedByVerifier + ) + } + + var openedWorkspaceIdentifierForCleanup: String? { + guard workspaceOpenedByVerifier, workspaceCloseAttempted == false else { + return nil + } + return workspaceIdentifier + } + + var openedInteractionSessionKeyForCleanup: String? { + guard interactionSessionOpenedByVerifier, + interactionSessionEndAttempted == false else { + return nil + } + return interactionSessionKey + } var navigatorRoot: String { "ProxyToolVerifierFixture" @@ -428,18 +767,62 @@ private struct VerificationState { "\(navigatorRoot)/\(path)" } - func arguments(for toolName: String) throws -> [String: MCPJSONValue] { + func executionDecision(for toolName: String) throws -> ToolExecutionDecision { + if workspaceSurface == .headless, + toolName == "DeviceInteractionStartSession", + availableTools.contains("DeviceInteractionStartWorkspaceSession") { + return .skip("headless verification uses DeviceInteractionStartWorkspaceSession") + } + let arguments: [String: MCPJSONValue] + do { + guard let plannedArguments = try plannedArguments(for: toolName) else { + return .skip("cataloged without a fixture-safe execution plan") + } + arguments = plannedArguments + } catch let unavailable as ToolPlanUnavailable { + return .skip(unavailable.reason) + } + guard let tool = toolsByName[toolName] else { + return .skip("missing tool descriptor") + } + guard let schema = ToolInputSchema(tool.inputSchema) else { + return .skip("tool descriptor has no object input schema") + } + let missingRequiredArguments = schema.required + .subtracting(Set(arguments.keys)) + .sorted() + if missingRequiredArguments.isEmpty == false { + return .skip( + "fixture-safe plan does not supply required arguments: " + + missingRequiredArguments.joined(separator: ", ") + ) + } + let unknownArguments = Set(arguments.keys).subtracting(schema.properties).sorted() + if unknownArguments.isEmpty == false { + return .skip( + "fixture-safe plan does not match current schema arguments: " + + unknownArguments.joined(separator: ", ") + ) + } + return .call(arguments) + } + + private func plannedArguments(for toolName: String) throws -> [String: MCPJSONValue]? { switch toolName { case "BuildProject": - return try withTab(["buildForTesting": .bool(true)]) + return try withWorkspaceScope(toolName, ["buildForTesting": .bool(true)]) case "DeviceInteractionEndSession": return ["interactionSessionKey": .string(interactionSessionKey)] case "DeviceInteractionInstallAndRun": - return try withTab([ + return try withWorkspaceScope(toolName, [ "interactionSessionKey": .string(interactionSessionKey), ]) case "DeviceInteractionStartSession": - return try withTab([ + return try withWorkspaceScope(toolName, [ + "sessionIdentifier": .string(interactionSessionIdentifier), + ]) + case "DeviceInteractionStartWorkspaceSession": + return try withWorkspaceScope(toolName, [ "sessionIdentifier": .string(interactionSessionIdentifier), ]) case "DeviceInteractionSynthesize": @@ -451,21 +834,21 @@ private struct VerificationState { case "DocumentationSearch": return ["query": .string("NavigationStack")] case "GetBuildLog": - return try withTab(["severity": .string("remark")]) + return try withWorkspaceScope(toolName, ["severity": .string("remark")]) case "GetConsoleOutput": - return try withTab([ + return try withWorkspaceScope(toolName, [ "outputType": .string("all"), "tailLimit": .integer(100), ]) case "GetCrashIssueLogs": - return try withTab([ + return try withWorkspaceScope(toolName, [ "signature_name": .string("ProxyVerifierCrashSignature"), "bundle_id": .string("dev.xcodemcp.ProxyToolVerifierFixture"), "platform": .string("macOS"), "app_version": .string("1.0"), ]) case "GetFieldPerformanceIssueLogs": - return try withTab([ + return try withWorkspaceScope(toolName, [ "app_version": .string("1.0"), "signature_name": .string("ProxyVerifierPerformanceSignature"), "diagnostic_type": .string("hangs"), @@ -473,51 +856,55 @@ private struct VerificationState { "platform": .string("macOS"), ]) case "GetFileCompilerFlags": - return try withTab([ + return try withWorkspaceScope(toolName, [ "targetName": .string("ProxyToolVerifierFixture"), "filePath": .string(navPath("VerifierCore.swift")), ]) + case "GetTargetBuildSettings": + return try withWorkspaceScope(toolName, [ + "targetName": .string("ProxyToolVerifierFixture"), + ]) case "GetTestList": - return try withTab() + return try withWorkspaceScope(toolName) case "GetTopCrashIssues": - return try withTab([ + return try withWorkspaceScope(toolName, [ "count": .integer(1), "bundle_id": .string("dev.xcodemcp.ProxyToolVerifierFixture"), "platform": .string("macOS"), ]) case "GetTopFieldPerformanceIssues": - return try withTab([ + return try withWorkspaceScope(toolName, [ "diagnostic_type": .string("hangs"), "bundle_id": .string("dev.xcodemcp.ProxyToolVerifierFixture"), "platform": .string("macOS"), ]) case "InvokeDebuggerCommand": - return try withTab([ + return try withWorkspaceScope(toolName, [ "command": .string("thread list"), "timeout": .integer(20), ]) case "LocalizationPlanner": - return try withTab([ + return try withWorkspaceScope(toolName, [ "targetLocaleIdentifier": .string("ja"), ]) case "RenderPreview": - return try withTab([ + return try withWorkspaceScope(toolName, [ "sourceFilePath": .string(navPath("ContentView.swift")), "timeout": .integer(180), ]) case "RunAllTests": - return try withTab() + return try withWorkspaceScope(toolName) case "RunCodeSnippet": - return try withTab([ + return try withWorkspaceScope(toolName, [ "sourceFilePath": .string(navPath("VerifierCore.swift")), "purpose": .string("Proxy verifier snippet"), "codeSnippet": .string(#"print(VerifierCore.message())"#), "timeout": .integer(120), ]) case "RunProject": - return try withTab(["attachDebugger": .bool(true)]) + return try withWorkspaceScope(toolName, ["attachDebugger": .bool(true)]) case "RunSomeTests": - return try withTab([ + return try withWorkspaceScope(toolName, [ "tests": .array([ .object([ "targetName": .string(testTargetName), @@ -526,120 +913,174 @@ private struct VerificationState { ]), ]) case "StopProject": - return try withTab() + return try withWorkspaceScope(toolName) case "StringCatalogContext": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("Localizable.xcstrings")), "stringKey": .string("verifier.title"), "targetLocaleIdentifier": .string("ja"), ]) case "StringCatalogEdit": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("Localizable.xcstrings")), "stringKey": .string("verifier.title"), "targetLocaleIdentifier": .string("ja"), "translation": .string("Verifier Title JA Updated"), ]) case "StringCatalogRead": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("Localizable.xcstrings")), "targetLocaleIdentifier": .string("ja"), "keyLimit": .integer(20), ]) case "UpdateFileCompilerFlags": - return try withTab([ + return try withWorkspaceScope(toolName, [ "targetName": .string("ProxyToolVerifierFixture"), "filePath": .string(navPath("VerifierCore.swift")), "compilerFlags": .string("-DPROXY_TOOL_VERIFIER"), "appendValue": .bool(false), ]) case "XcodeGetCurrentFile": - return try withTab([ + return try withWorkspaceScope(toolName, [ "includeContent": .bool(false), "includeSelection": .bool(true), ]) case "XcodeGlob": - return try withTab([ + return try withWorkspaceScope(toolName, [ "pattern": .string("**/*.swift"), ]) case "XcodeGrep": - return try withTab([ + return try withWorkspaceScope(toolName, [ "pattern": .string("VerifierCore"), "outputMode": .string("filesWithMatches"), "headLimit": .integer(10), ]) case "XcodeListNavigatorIssues": - return try withTab(["severity": .string("remark")]) + return try withWorkspaceScope(toolName, ["severity": .string("remark")]) case "XcodeListRunDestinations": - return try withTab(["includeIncompatible": .bool(true)]) + return try withWorkspaceScope(toolName, ["includeIncompatible": .bool(true)]) case "XcodeListSchemes": - return try withTab() - case "XcodeListWindows": + return try withWorkspaceScope(toolName) + case "XcodeListTargets", "XcodeListTestPlans": + return try withWorkspaceScope(toolName) + case "XcodeListTemplates", "XcodeListWindows", "XcodeListWorkspaces": return [:] case "XcodeLS": - return try withTab([ + return try withWorkspaceScope(toolName, [ "path": .string(navigatorRoot), "recursive": .bool(true), ]) case "XcodeMakeDir": - return try withTab([ + return try withWorkspaceScope(toolName, [ "directoryPath": .string(navPath("VerifierScratch")), ]) case "XcodeMV": - return try withTab([ + return try withWorkspaceScope(toolName, [ "sourcePath": .string(navPath("VerifierScratch/probe.txt")), "destinationPath": .string(navPath("VerifierScratch/probe-moved.txt")), "operation": .object(["rawValue": .string("move")]), "overwriteExisting": .bool(true), ]) case "XcodeRead": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("VerifierCore.swift")), "limit": .integer(40), ]) case "XcodeRefreshCodeIssuesInFile": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("VerifierCore.swift")), ]) case "XcodeRM": - return try withTab([ + return try withWorkspaceScope(toolName, [ "path": .string(navPath("VerifierScratch/probe-moved.txt")), "recursive": .bool(false), "deleteFiles": .bool(true), ]) case "XcodeSwitchRunDestination": - return try withTab(["displayTitle": .string(runDestination)]) + return try withWorkspaceScope(toolName, ["displayTitle": .string(runDestination)]) case "XcodeSwitchScheme": - return try withTab(["schemeName": .string(schemeName)]) + return try withWorkspaceScope(toolName, ["schemeName": .string(schemeName)]) case "XcodeUpdate": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("VerifierScratch/probe.txt")), "oldString": .string("initial"), "newString": .string("updated"), "replaceAll": .bool(false), ]) case "XcodeWrite": - return try withTab([ + return try withWorkspaceScope(toolName, [ "filePath": .string(navPath("VerifierScratch/probe.txt")), "content": .string("proxy verifier initial content\n"), ]) default: - return [:] + return nil } } - private func withTab(_ arguments: [String: MCPJSONValue] = [:]) throws -> [String: MCPJSONValue] { - guard let tabIdentifier else { - throw VerifierFailure( - "fixture Xcode tab has not been resolved; refusing to call tab-scoped tool" - ) + private func withWorkspaceScope( + _ toolName: String, + _ arguments: [String: MCPJSONValue] = [:] + ) throws -> [String: MCPJSONValue] { + guard let schema = toolsByName[toolName].flatMap({ ToolInputSchema($0.inputSchema) }) else { + throw ToolPlanUnavailable(reason: "tool descriptor has no object input schema") } var result = arguments - result["tabIdentifier"] = .string(tabIdentifier) - return result + switch workspaceSurface { + case .headless: + guard schema.properties.contains("workspaceIdentifier") else { + throw ToolPlanUnavailable( + reason: "headless schema has no workspaceIdentifier argument" + ) + } + guard let workspaceIdentifier else { + throw VerifierFailure( + "headless workspace has not been opened; refusing to call \(toolName)" + ) + } + result["workspaceIdentifier"] = .string(workspaceIdentifier) + return result + case .gui: + guard schema.properties.contains("tabIdentifier") else { + throw ToolPlanUnavailable( + reason: "GUI schema has no tabIdentifier argument" + ) + } + guard let tabIdentifier else { + throw VerifierFailure( + "fixture Xcode tab has not been resolved; refusing to call \(toolName)" + ) + } + result["tabIdentifier"] = .string(tabIdentifier) + return result + } } mutating func observe(toolName: String, record: ToolVerificationRecord) throws { + if toolName == "XcodeOpenWorkspace" { + guard record.status == .passed else { + throw VerifierFailure( + "XcodeOpenWorkspace failed: \(record.detail)" + ) + } + guard let rawResult = record.rawResult, + let identifier = parseFirstString(named: "workspaceIdentifier", from: rawResult) + else { + throw VerifierFailure( + "XcodeOpenWorkspace did not return workspaceIdentifier; " + + "no workspace close authority was acquired" + ) + } + workspaceIdentifier = identifier + workspaceReportedPath = parseFirstString(named: "workspacePath", from: rawResult) + ?? parseFirstString(named: "path", from: rawResult) + workspaceOpenedByVerifier = true + return + } + if toolName == "DeviceInteractionEndSession" { + observeDeviceInteractionEnd(record: record) + return + } + guard let rawResult = record.rawResult else { return } switch toolName { case "XcodeListWindows": @@ -674,16 +1115,43 @@ private struct VerificationState { testTargetName = test.targetName testIdentifier = test.identifier } - case "DeviceInteractionStartSession": + case "DeviceInteractionStartSession", "DeviceInteractionStartWorkspaceSession": if let key = parseFirstString(named: "interactionSessionKey", from: rawResult) ?? parseFirstString(named: "interactSessionKey", from: rawResult) ?? parseFirstString(named: "sessionKey", from: rawResult) { interactionSessionKey = key + interactionSessionOpenedByVerifier = true } default: break } } + + mutating func observeWorkspaceClose(record: ToolVerificationRecord) { + workspaceCloseAttempted = true + workspaceClosedByVerifier = record.status == .passed + } + + mutating func observeDeviceInteractionEnd(record _: ToolVerificationRecord) { + interactionSessionEndAttempted = true + } +} + +private struct ToolInputSchema { + let properties: Set + let required: Set + + init?(_ value: MCPJSONValue?) { + guard let object = value?.objectValue else { + return nil + } + properties = Set( + object["properties"]?.objectValue?.keys.map { $0 } ?? [] + ) + required = Set( + object["required"]?.arrayValue?.compactMap(\.stringValue) ?? [] + ) + } } private struct FixtureLayout { @@ -777,8 +1245,10 @@ private final class RunningProcess { private struct VerificationReport: Codable { let endpoint: String + let xcodeMode: VerifierXcodeMode let fixturePath: String let workspacePath: String + let workspace: WorkspaceVerificationRecord let toolCount: Int let availableTools: [String] let toolDescriptors: [MCPTool] @@ -789,6 +1259,13 @@ private struct VerificationReport: Codable { } } +private struct ToolCatalogArtifact: Codable { + let mode: VerifierXcodeMode + let catalogTool: String + let toolCount: Int + let tools: [MCPJSONValue] +} + private struct ToolVerificationRecord: Codable { let name: String let status: ToolVerificationStatus @@ -796,6 +1273,7 @@ private struct ToolVerificationRecord: Codable { let detail: String let arguments: [String: MCPJSONValue]? let rawResult: MCPJSONValue? + let rawProgress: [MCPJSONValue]? init( name: String, @@ -803,7 +1281,8 @@ private struct ToolVerificationRecord: Codable { elapsedSeconds: TimeInterval, detail: String, arguments: [String: MCPJSONValue]?, - rawResult: MCPJSONValue? = nil + rawResult: MCPJSONValue? = nil, + rawProgress: [MCPJSONValue]? = nil ) { self.name = name self.status = status @@ -811,11 +1290,13 @@ private struct ToolVerificationRecord: Codable { self.detail = detail self.arguments = arguments self.rawResult = rawResult + self.rawProgress = rawProgress } } private enum ToolVerificationStatus: String, Codable, CaseIterable { case passed + case notPlanned = "not-planned" case externalPrerequisite case toolError case rpcError @@ -824,7 +1305,7 @@ private enum ToolVerificationStatus: String, Codable, CaseIterable { var isHardFailure: Bool { switch self { - case .passed, .externalPrerequisite: + case .passed, .notPlanned, .externalPrerequisite: return false case .toolError, .rpcError, .failed, .hung: return true @@ -832,6 +1313,18 @@ private enum ToolVerificationStatus: String, Codable, CaseIterable { } } +private actor RawProgressRecorder { + private var values: [MCPJSONValue] = [] + + func append(_ value: MCPJSONValue) { + values.append(value) + } + + func snapshot() -> [MCPJSONValue] { + values + } +} + private func verificationStatus( toolName: String, result: MCPToolResult, @@ -865,6 +1358,7 @@ private func isExternalPrerequisiteResult(toolName: String, detail: String) -> B let deviceTools: Set = [ "DeviceInteractionStartSession", + "DeviceInteractionStartWorkspaceSession", "DeviceInteractionInstallAndRun", "DeviceInteractionSynthesize", "DeviceInteractionEndSession", @@ -878,10 +1372,27 @@ private func isExternalPrerequisiteResult(toolName: String, detail: String) -> B return false } -private func toolExecutionOrder(availableTools: Set) -> [String] { +private let workspaceLifecycleToolNames: Set = [ + "XcodeOpenWorkspace", + "XcodeCloseWorkspace", +] + +private let progressReportingToolNames: Set = [ + "BuildProject", + "DeviceInteractionInstallAndRun", + "RunAllTests", + "RunProject", + "RunSomeTests", +] + +private func toolExecutionOrder( + availableTools: Set, + excluding excludedTools: Set +) -> [String] { let known = orderedKnownToolNames() - let plannedKnown = known.filter { availableTools.contains($0) } - let unknown = availableTools.subtracting(Set(known)).sorted() + let eligibleTools = availableTools.subtracting(excludedTools) + let plannedKnown = known.filter { eligibleTools.contains($0) } + let unknown = eligibleTools.subtracting(Set(known)).sorted() return plannedKnown + unknown } @@ -889,6 +1400,7 @@ private func orderedKnownToolNames() -> [String] { deduplicated( catalogToolNames() + bootstrapToolNames() + + projectConfigurationToolNames() + navigatorToolNames() + stringCatalogToolNames() + buildToolNames() @@ -901,6 +1413,7 @@ private func orderedKnownToolNames() -> [String] { private func catalogToolNames() -> [String] { [ "XcodeListWindows", + "XcodeListWorkspaces", "XcodeListSchemes", "XcodeListRunDestinations", "DocumentationSearch", @@ -910,10 +1423,26 @@ private func catalogToolNames() -> [String] { private func bootstrapToolNames() -> [String] { [ "XcodeListWindows", + "XcodeListWorkspaces", "XcodeListSchemes", "XcodeListRunDestinations", + "XcodeListTargets", + "XcodeListTestPlans", + "XcodeListTemplates", + "GetTargetBuildSettings", "XcodeSwitchScheme", "XcodeSwitchRunDestination", + "XcodeSwitchTestPlan", + ] +} + +private func projectConfigurationToolNames() -> [String] { + [ + "AddEntitlement", + "AddInfoPlist", + "UpdateTargetBuildSetting", + "XcodeNewProject", + "XcodeNewTarget", ] } @@ -978,6 +1507,7 @@ private func fieldReportToolNames() -> [String] { private func deviceInteractionToolNames() -> [String] { [ "DeviceInteractionStartSession", + "DeviceInteractionStartWorkspaceSession", "DeviceInteractionInstallAndRun", "DeviceInteractionSynthesize", "DeviceInteractionEndSession", diff --git a/Tests/ProxyCLITests/CLIUsageContractTests.swift b/Tests/ProxyCLITests/CLIUsageContractTests.swift index e8035be2..c0354fd5 100644 --- a/Tests/ProxyCLITests/CLIUsageContractTests.swift +++ b/Tests/ProxyCLITests/CLIUsageContractTests.swift @@ -20,6 +20,7 @@ struct CLIUsageContractTests { "--upstream-arg ", "--upstream-processes ", "--session-id ", + "--xcode-mode ", "--refresh-code-issues-mode ", "--force-restart", "--dry-run", diff --git a/Tests/ProxyCLITests/ServerCommandTests.swift b/Tests/ProxyCLITests/ServerCommandTests.swift index f5423463..55c9262a 100644 --- a/Tests/ProxyCLITests/ServerCommandTests.swift +++ b/Tests/ProxyCLITests/ServerCommandTests.swift @@ -17,6 +17,8 @@ struct ServerCommandTests { #expect(config.requestTimeout == 300) #expect(config.autoApproveXcodeDialog == false) #expect(config.refreshCodeIssuesMode == .proxy) + #expect(config.xcodeMode == .automatic) + #expect(config.upstreamKind == .stockMCPBridge) } @Test func serverCommandMapsTypedOptionsToConfiguration() throws { @@ -45,6 +47,7 @@ struct ServerCommandTests { #expect(config.requestTimeout == 12.5) #expect(config.autoApproveXcodeDialog) #expect(config.refreshCodeIssuesMode == .upstream) + #expect(config.upstreamKind == .custom) } @Test func serverCommandAllowsPortZeroAndZeroTimeout() throws { @@ -83,6 +86,7 @@ struct ServerCommandTests { ["--upstream-processes", "0"], ["--upstream-processes", "11"], ["--upstream-processes", "abc"], + ["--xcode-mode", "invalid"], ] for arguments in invalidInvocations { @@ -92,6 +96,29 @@ struct ServerCommandTests { } } + @Test func serverCommandResolvesExplicitXcodeModesForStockUpstream() throws { + for mode in ProxyConfig.XcodeMode.allCasesForTesting { + let config = try resolvedProxyConfig( + arguments: ["--xcode-mode", mode.rawValue] + ) + #expect(config.xcodeMode == mode) + #expect(config.upstreamKind == .stockMCPBridge) + } + } + + @Test func serverCommandRejectsExplicitXcodeModeWithCustomUpstream() { + for mode in [ProxyConfig.XcodeMode.gui, .headless] { + #expect(throws: CLICommandError.self) { + _ = try resolvedProxyConfig( + arguments: [ + "--upstream-command", "/tmp/custom-bridge", + "--xcode-mode", mode.rawValue, + ] + ) + } + } + } + @Test func serverCommandRejectsConflictingAddressOptions() { #expect(throws: CLICommandError.self) { _ = try resolvedProxyConfig( @@ -240,3 +267,7 @@ private func makeTempConfigFile(_ contents: String) throws -> URL { } private struct UnexpectedServerCommandAction: Error {} + +private extension ProxyConfig.XcodeMode { + static let allCasesForTesting: [Self] = [.automatic, .gui, .headless] +} diff --git a/Tests/ProxyCLITests/ServerRunnerTests.swift b/Tests/ProxyCLITests/ServerRunnerTests.swift index 2f7f6d78..a8234fa8 100644 --- a/Tests/ProxyCLITests/ServerRunnerTests.swift +++ b/Tests/ProxyCLITests/ServerRunnerTests.swift @@ -332,6 +332,16 @@ struct ServerRunnerTests { #expect(line.contains("--auto-approve")) } + @Test func serverRunnerDryRunPrintsExplicitXcodeMode() async throws { + let result = await runServer( + arguments: ["xcode-mcp-proxy-server", "--xcode-mode", "headless", "--dry-run"] + ) + + #expect(result.exitCode == 0) + #expect(result.stderr.isEmpty) + #expect(result.stdout.first?.contains("--xcode-mode headless") == true) + } + @Test func serverRunnerDryRunPreservesExplicitProxyRefreshMode() async throws { let result = await runServer( arguments: [ diff --git a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerBuildInfoTests.swift b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerBuildInfoTests.swift index 9ceb2926..48722887 100644 --- a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerBuildInfoTests.swift +++ b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerBuildInfoTests.swift @@ -29,6 +29,7 @@ struct XcodeMCPProxyServerBuildInfoTests { displayHost: "localhost", port: 8765, config: config, + xcodeMode: .gui, xcodeTargets: [ ProxyRuntimeInventorySnapshot.XcodeTarget( processID: target.processID, @@ -53,4 +54,38 @@ struct XcodeMCPProxyServerBuildInfoTests { DocumentationSearch: pending """) } + + @Test func headlessStartupSummaryNamesTheServiceAndUpstreamDocumentationOwner() { + let config = ProxyConfig( + listenHost: "localhost", + listenPort: 8765, + upstreamCommand: MCPBridgeInvocation.defaultMCPBridge.command, + upstreamArgs: MCPBridgeInvocation.defaultMCPBridge.arguments, + maxBodyBytes: 1_048_576, + requestTimeout: 300, + autoApproveXcodeDialog: true + ) + + let summary = XcodeMCPProxyServer.startupSummary( + displayHost: "localhost", + port: 8765, + config: config, + xcodeMode: .headless, + xcodeTargets: [] + ) + + #expect(summary == """ + XcodeMCPProxyKit \(XcodeMCPProxyServer.productMetadata.version) + + Server + URL: http://localhost:8765/mcp + Upstream processes: 1 + Auto approve: disabled + + Xcode + Mode: headless + Status: Xcode Service + DocumentationSearch: upstream + """) + } } diff --git a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift index 9805c74a..e799208d 100644 --- a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift +++ b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift @@ -296,6 +296,229 @@ struct XcodeMCPProxyServerTests { try await server.shutdown() } + @Test func automaticEnabledHeadlessSkipsGUIAutomationAndUsesUnboundFeatures() async throws { + let availabilityQueries = NIOLockedValueBox(0) + let autoApproverCreations = NIOLockedValueBox(0) + let runtimeConfiguration = NIOLockedValueBox(nil) + let runtime = StartupInventoryRuntime() + let server = XcodeMCPProxyServer( + configuration: .init( + bindAddress: .init(host: "127.0.0.1", port: 0), + discovery: .disabled, + approvalPolicy: .automatic, + featurePolicy: .init(refreshCodeIssuesMode: .proxy) + ), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { + availabilityQueries.withLockedValue { $0 += 1 } + return .enabled + }, + makeAutoApprover: { _, _ in + autoApproverCreations.withLockedValue { $0 += 1 } + return RecordingAutoApprover() + }, + makeRuntime: { config in + runtimeConfiguration.withLockedValue { $0 = config } + return runtime + } + ) + ) + + _ = try await server.start() + let captured = try #require(runtimeConfiguration.withLockedValue { $0 }) + #expect(availabilityQueries.withLockedValue { $0 } == 1) + #expect(captured.xcodeMode == .headless) + #expect(captured.usesPermissionDialogAutomation == false) + #expect(captured.refreshCodeIssuesMode == .upstream) + #expect(ProxyRuntime.supportsProcessBoundRouting(configuration: captured) == false) + #expect(ProxyRuntime.documentationSearchIsConfigured(configuration: captured) == false) + #expect(autoApproverCreations.withLockedValue { $0 } == 0) + try await server.shutdown() + } + + @Test func explicitGUIPreservesLegacyRoutingWithoutStatusQuery() async throws { + let availabilityQueries = NIOLockedValueBox(0) + let runtimeConfiguration = NIOLockedValueBox(nil) + let runtime = StartupInventoryRuntime() + let server = XcodeMCPProxyServer( + configuration: .init( + bindAddress: .init(host: "127.0.0.1", port: 0), + discovery: .disabled, + xcodeMode: .gui + ), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { + availabilityQueries.withLockedValue { $0 += 1 } + return .enabled + }, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { config in + runtimeConfiguration.withLockedValue { $0 = config } + return runtime + } + ) + ) + + _ = try await server.start() + let captured = try #require(runtimeConfiguration.withLockedValue { $0 }) + #expect(availabilityQueries.withLockedValue { $0 } == 0) + #expect(captured.xcodeMode == .gui) + #expect(ProxyRuntime.supportsProcessBoundRouting(configuration: captured)) + try await server.shutdown() + } + + @Test func customAutomaticUpstreamPreservesUnboundModeWithoutStatusQuery() async throws { + let availabilityQueries = NIOLockedValueBox(0) + let runtimeConfiguration = NIOLockedValueBox(nil) + let runtime = StartupInventoryRuntime() + let server = XcodeMCPProxyServer( + configuration: .init( + bindAddress: .init(host: "127.0.0.1", port: 0), + upstream: .custom(command: "/bin/echo", arguments: []), + discovery: .disabled + ), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { + availabilityQueries.withLockedValue { $0 += 1 } + return .enabled + }, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { config in + runtimeConfiguration.withLockedValue { $0 = config } + return runtime + } + ) + ) + + _ = try await server.start() + let captured = try #require(runtimeConfiguration.withLockedValue { $0 }) + #expect(availabilityQueries.withLockedValue { $0 } == 0) + #expect(captured.xcodeMode == .custom) + #expect(ProxyRuntime.supportsProcessBoundRouting(configuration: captured) == false) + try await server.shutdown() + } + + @Test func explicitHeadlessDisabledFailsBeforeRuntimeAcquisition() async { + let runtimeCreations = NIOLockedValueBox(0) + let server = XcodeMCPProxyServer( + configuration: .init( + discovery: .disabled, + xcodeMode: .headless + ), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { .disabled }, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { _ in + runtimeCreations.withLockedValue { $0 += 1 } + return StartupInventoryRuntime() + } + ) + ) + + await #expect(throws: XcodeMCPProxyServer.LifecycleError.self) { + _ = try await server.start() + } + #expect(runtimeCreations.withLockedValue { $0 } == 0) + } + + @Test func explicitModeRejectsCustomUpstreamBeforeRuntimeAcquisition() async { + let runtimeCreations = NIOLockedValueBox(0) + let server = XcodeMCPProxyServer( + configuration: .init( + upstream: .custom(command: "/bin/echo", arguments: []), + discovery: .disabled, + xcodeMode: .gui + ), + dependencies: .init( + discoveryClient: .testValue, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { _ in + runtimeCreations.withLockedValue { $0 += 1 } + return StartupInventoryRuntime() + } + ) + ) + + await #expect(throws: XcodeMCPProxyServer.LifecycleError.self) { + _ = try await server.start() + } + #expect(runtimeCreations.withLockedValue { $0 } == 0) + } + + @Test func cancellingStartCancelsAndAwaitsHeadlessStatusResolution() async throws { + let availability = CancellationControlledHeadlessAvailability() + let runtimeCreations = NIOLockedValueBox(0) + let server = XcodeMCPProxyServer( + configuration: .init(discovery: .disabled), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { + try await availability.resolve() + }, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { _ in + runtimeCreations.withLockedValue { $0 += 1 } + return StartupInventoryRuntime() + } + ) + ) + let startTask = Task { + try await server.start() + } + + try await availability.started.wait(description: "waiting for headless status resolution") + startTask.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await startTask.value + } + try await availability.completed.wait( + description: "waiting for cancelled headless status unwind" + ) + #expect(availability.wasCancelled) + #expect(runtimeCreations.withLockedValue { $0 } == 0) + #expect((await server.snapshot()).phase == .stopped) + } + + @Test func shutdownWhileStartingCancelsAndAwaitsHeadlessStatusResolution() async throws { + let availability = CancellationControlledHeadlessAvailability() + let runtimeCreations = NIOLockedValueBox(0) + let server = XcodeMCPProxyServer( + configuration: .init(discovery: .disabled), + dependencies: .init( + discoveryClient: .testValue, + headlessMCPAvailability: { + try await availability.resolve() + }, + makeAutoApprover: { _, _ in RecordingAutoApprover() }, + makeRuntime: { _ in + runtimeCreations.withLockedValue { $0 += 1 } + return StartupInventoryRuntime() + } + ) + ) + let startTask = Task { + try await server.start() + } + + try await availability.started.wait(description: "waiting for headless status resolution") + try await server.shutdown() + + await #expect(throws: CancellationError.self) { + _ = try await startTask.value + } + try await availability.completed.wait( + description: "waiting for shutdown status unwind" + ) + #expect(availability.wasCancelled) + #expect(runtimeCreations.withLockedValue { $0 } == 0) + #expect((await server.snapshot()).phase == .stopped) + } + @Test func startRejectsRepeatedStartsOnSameServerInstance() async throws { let autoApprover = RecordingAutoApprover() let upstream = RecordingUpstreamSlot() @@ -732,6 +955,30 @@ private final class BlockingAutoApprover: @unchecked Sendable, } } +private final class CancellationControlledHeadlessAvailability: @unchecked Sendable { + let started = TestSignal() + let completed = TestSignal() + + private let release = TestSignal() + private let cancelled = NIOLockedValueBox(false) + + var wasCancelled: Bool { + cancelled.withLockedValue { $0 } + } + + func resolve() async throws -> XcodeMCPServerAvailability { + started.signal() + defer { completed.signal() } + do { + try await release.waitUntilSignaled() + return .enabled + } catch is CancellationError { + cancelled.withLockedValue { $0 = true } + throw CancellationError() + } + } +} + private final class StartupInventoryRuntime: @unchecked Sendable, ProxyRuntimeServing { private struct State { var started = false diff --git a/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift b/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift new file mode 100644 index 00000000..a8b61814 --- /dev/null +++ b/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift @@ -0,0 +1,347 @@ +import Foundation +import Testing +import XcodeMCPKit +@testable import XcodeMCPProxyKit +import XcodeMCPProxyRuntime + +@Suite +struct XcodeMCPServerStatusClientTests { + @Test func unavailableToolIsANormalAvailabilityResult() async throws { + let requests = StatusLockedBox<[ProcessRequest]>([]) + let client = makeClient(requests: requests) { _ in + ProcessOutput( + terminationStatus: XcodeMCPServerStatusClient.toolNotFoundExitStatus, + stdout: "", + stderr: "xcrun: error: unable to find utility" + ) + } + + #expect(try await client.availability() == .unavailable) + #expect(requests.withLockedValue { $0.count } == 1) + } + + @Test func disabledStatusDecodesOnlyPermissionEnabled() async throws { + let client = makeClient { request in + if request.label == "discover-xcode-mcp-server" { + return foundToolOutput() + } + return ProcessOutput( + terminationStatus: 0, + stdout: """ + { + "openWorkspaces": [], + "permission": { + "enabled": false, + "unsafeAlwaysAllowAllAgents": false + }, + "running": false, + "futureField": { "value": 1 } + } + """, + stderr: "" + ) + } + + #expect(try await client.availability() == .disabled) + } + + @Test func emptyDiscoveredExecutablePathIsRejectedBeforeStatus() async { + let requests = StatusLockedBox<[ProcessRequest]>([]) + let client = makeClient(requests: requests) { _ in + ProcessOutput(terminationStatus: 0, stdout: " \n", stderr: "") + } + + await #expect(throws: XcodeMCPServerStatusClient.Failure.discoveryReturnedNoPath) { + _ = try await client.availability() + } + #expect(requests.withLockedValue { $0.count } == 1) + } + + @Test func enabledStatusAcceptsDynamicOpenWorkspacesAndNonzeroExit() async throws { + let client = makeClient { request in + if request.label == "discover-xcode-mcp-server" { + return foundToolOutput() + } + return ProcessOutput( + terminationStatus: 1, + stdout: """ + { + "openWorkspaces": [ + { + "path": "/tmp/App.xcworkspace", + "displayName": "App", + "activeSchemeName": "App" + } + ], + "permission": { + "enabled": true, + "unsafeAlwaysAllowAllAgents": false + }, + "running": true + } + """, + stderr: "mcp-server: warning: live service query timed out" + ) + } + + #expect(try await client.availability() == .enabled) + } + + @Test func malformedSuccessfulStatusIsDiagnosticFailure() async { + let client = makeClient { request in + request.label == "discover-xcode-mcp-server" + ? foundToolOutput() + : ProcessOutput(terminationStatus: 0, stdout: "{}", stderr: "") + } + + await #expect(throws: XcodeMCPServerStatusClient.Failure.malformedStatus) { + _ = try await client.availability() + } + } + + @Test func statusFailurePreservesExitDiagnosticsWhenJSONIsInvalid() async { + let client = makeClient { request in + request.label == "discover-xcode-mcp-server" + ? foundToolOutput() + : ProcessOutput( + terminationStatus: 2, + stdout: "not-json", + stderr: "status failed" + ) + } + + await #expect( + throws: XcodeMCPServerStatusClient.Failure.statusFailed( + exitStatus: 2, + stderr: "status failed" + ) + ) { + _ = try await client.availability() + } + } + + @Test func timeoutIsReportedAndCancellationRemainsCancellation() async { + let timeoutClient = makeClient { request in + throw ProcessTimeoutError(label: request.label) + } + await #expect( + throws: XcodeMCPServerStatusClient.Failure.timedOut( + operation: "mcp-server discovery" + ) + ) { + _ = try await timeoutClient.availability() + } + + let cancelledClient = makeClient { _ in + throw CancellationError() + } + await #expect(throws: CancellationError.self) { + _ = try await cancelledClient.availability() + } + } + + @Test func statusCommandsUseBoundedProcessRunnerRequests() async throws { + let requests = StatusLockedBox<[ProcessRequest]>([]) + let client = makeClient(requests: requests) { request in + request.label == "discover-xcode-mcp-server" + ? foundToolOutput() + : enabledStatusOutput() + } + + _ = try await client.availability() + let recorded = requests.withLockedValue { $0 } + #expect(recorded.count == 2) + #expect(recorded[0].executablePath == MCPBridgeInvocation.xcrunCommand) + #expect(recorded[0].arguments == ["--find", "mcp-server"]) + #expect( + recorded[0].timeoutNanoseconds + == XcodeMCPServerStatusClient.discoveryTimeoutNanoseconds + ) + #expect( + recorded[1].executablePath + == "/Applications/Xcode.app/Contents/Developer/usr/bin/mcp-server" + ) + #expect(recorded[1].arguments == ["status", "--format", "json"]) + #expect( + recorded[1].timeoutNanoseconds + == XcodeMCPServerStatusClient.statusTimeoutNanoseconds + ) + } +} + +@Suite +struct XcodeConnectionModeResolverTests { + @Test func automaticUsesHeadlessOnlyWhenEnabled() async throws { + let enabled = try await resolve(mode: .automatic, availability: .enabled) + #expect(enabled.xcodeMode == .headless) + #expect(enabled.diagnostic == nil) + + let unavailable = try await resolve(mode: .automatic, availability: .unavailable) + #expect(unavailable.xcodeMode == .gui) + #expect(unavailable.diagnostic == nil) + } + + @Test func automaticDisabledUsesExactApprovedNoticeAndGUI() async throws { + let resolution = try await resolve(mode: .automatic, availability: .disabled) + + #expect(resolution.xcodeMode == .gui) + #expect( + resolution.diagnostic + == .notice(XcodeConnectionModeResolver.disabledNotice) + ) + #expect( + XcodeConnectionModeResolver.disabledNotice == """ + Xcode 27 headless MCP is available but disabled. + + To enable it, run: + + sudo xcrun mcp-server enable + + XcodeMCPKit will continue using GUI Xcode routing. + """) + } + + @Test func automaticFailureWarnsAndUsesGUI() async throws { + let config = makeProxyConfig(xcodeMode: .automatic) + let resolution = try await XcodeConnectionModeResolver.resolve(config: config) { + throw ProbeFailure.expected + } + + #expect(resolution.xcodeMode == .gui) + guard case .warning(let message) = resolution.diagnostic else { + Issue.record("expected a warning diagnostic") + return + } + #expect(message.contains("Unable to determine Xcode headless MCP status")) + #expect(message.contains("continue using GUI Xcode routing")) + } + + @Test func explicitGUIAndCustomAutomaticNeverQueryStatus() async throws { + let queryCount = StatusLockedBox(0) + let query: @Sendable () async throws -> XcodeMCPServerAvailability = { + queryCount.withLockedValue { $0 += 1 } + return .enabled + } + + let gui = try await XcodeConnectionModeResolver.resolve( + config: makeProxyConfig(xcodeMode: .gui), + availability: query + ) + let custom = try await XcodeConnectionModeResolver.resolve( + config: makeProxyConfig( + xcodeMode: .automatic, + upstreamKind: .custom + ), + availability: query + ) + + #expect(gui.xcodeMode == .gui) + #expect(custom.xcodeMode == .custom) + #expect(queryCount.withLockedValue { $0 } == 0) + } + + @Test func explicitHeadlessDoesNotFallback() async { + let disabled = makeProxyConfig(xcodeMode: .headless) + await #expect(throws: XcodeMCPProxyServer.LifecycleError.self) { + _ = try await XcodeConnectionModeResolver.resolve(config: disabled) { + .disabled + } + } + + let unavailable = makeProxyConfig(xcodeMode: .headless) + await #expect(throws: XcodeMCPProxyServer.LifecycleError.self) { + _ = try await XcodeConnectionModeResolver.resolve(config: unavailable) { + .unavailable + } + } + + let failed = makeProxyConfig(xcodeMode: .headless) + await #expect(throws: XcodeMCPProxyServer.LifecycleError.self) { + _ = try await XcodeConnectionModeResolver.resolve(config: failed) { + throw ProbeFailure.expected + } + } + } + + private func resolve( + mode: ProxyConfig.XcodeMode, + availability: XcodeMCPServerAvailability + ) async throws -> XcodeConnectionModeResolver.Resolution { + try await XcodeConnectionModeResolver.resolve( + config: makeProxyConfig(xcodeMode: mode) + ) { + availability + } + } +} + +private struct StubProcessRunner: ProcessRunning { + let runOperation: @Sendable (ProcessRequest) async throws -> ProcessOutput + + func run(_ request: ProcessRequest) async throws -> ProcessOutput { + try await runOperation(request) + } +} + +private func makeClient( + requests: StatusLockedBox<[ProcessRequest]>? = nil, + run: @escaping @Sendable (ProcessRequest) async throws -> ProcessOutput +) -> XcodeMCPServerStatusClient { + XcodeMCPServerStatusClient( + processRunner: StubProcessRunner { request in + requests?.withLockedValue { $0.append(request) } + return try await run(request) + } + ) +} + +private func foundToolOutput() -> ProcessOutput { + ProcessOutput( + terminationStatus: 0, + stdout: "/Applications/Xcode.app/Contents/Developer/usr/bin/mcp-server\n", + stderr: "" + ) +} + +private func enabledStatusOutput() -> ProcessOutput { + ProcessOutput( + terminationStatus: 0, + stdout: #"{"permission":{"enabled":true}}"#, + stderr: "" + ) +} + +private func makeProxyConfig( + xcodeMode: ProxyConfig.XcodeMode, + upstreamKind: ProxyConfig.UpstreamKind = .stockMCPBridge +) -> ProxyConfig { + ProxyConfig( + listenHost: "localhost", + listenPort: 0, + upstreamCommand: MCPBridgeInvocation.defaultMCPBridge.command, + upstreamArgs: MCPBridgeInvocation.defaultMCPBridge.arguments, + upstreamKind: upstreamKind, + xcodeMode: xcodeMode, + maxBodyBytes: 1_048_576, + requestTimeout: 300 + ) +} + +private enum ProbeFailure: Error { + case expected +} + +private final class StatusLockedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLockedValue(_ operation: (inout Value) -> Result) -> Result { + lock.lock() + defer { lock.unlock() } + return operation(&value) + } +} diff --git a/Tests/PublicProductContractTests/PublicProductContractTests.swift b/Tests/PublicProductContractTests/PublicProductContractTests.swift index 55615d4a..4cde713e 100644 --- a/Tests/PublicProductContractTests/PublicProductContractTests.swift +++ b/Tests/PublicProductContractTests/PublicProductContractTests.swift @@ -1111,7 +1111,8 @@ func compileOnlyProxyConfigurationSurface() { featurePolicy: .init( prewarmToolsList: false, refreshCodeIssuesMode: .proxy - ) + ), + xcodeMode: .headless ) let customUpstreamConfig = XcodeMCPProxyServerConfiguration( upstream: .custom( @@ -1127,6 +1128,7 @@ func compileOnlyProxyConfigurationSurface() { let typedCapabilities: [String: MCPJSONValue]? = typedHandshake?.capabilities let metadataIsNull = typedCapabilities?["experimental"]?.objectValue?["metadata"]?.isNull let upstreamMode = XcodeMCPProxyServerConfiguration.RefreshCodeIssuesMode.upstream + let xcodeMode = XcodeMCPProxyServerConfiguration.XcodeMode.automatic let server = XcodeMCPProxyServer(configuration: config) let adapterConfig = XcodeMCPProxyStdioAdapterConfiguration( endpoint: .url(URL(string: "http://localhost:8765/mcp")!), @@ -1142,6 +1144,7 @@ func compileOnlyProxyConfigurationSurface() { typedCapabilities, metadataIsNull, upstreamMode, + xcodeMode, server, adapterConfig, adapter diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift new file mode 100644 index 00000000..b6a09d4a --- /dev/null +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import XcodeMCPProxyRuntime + +@Suite struct DeviceInteractionAffinityAuthorityTests { + @Test func decodesSessionStartTools() { + #expect( + DeviceInteractionToolCall.decode( + toolCall(name: "DeviceInteractionStartSession", arguments: [:]) + ) == .startsSession + ) + #expect( + DeviceInteractionToolCall.decode( + toolCall(name: "DeviceInteractionStartWorkspaceSession", arguments: [:]) + ) == .startsSession + ) + } + + @Test func decodesBothContinuationKeySpellings() { + #expect( + DeviceInteractionToolCall.decode( + toolCall( + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "device-key"] + ) + ) == .continuesSession(key: "device-key", endsSession: false) + ) + #expect( + DeviceInteractionToolCall.decode( + toolCall( + name: "DeviceInteractionInstallAndRun", + arguments: ["interactionSessionKey": "device-key"] + ) + ) == .continuesSession(key: "device-key", endsSession: false) + ) + #expect( + DeviceInteractionToolCall.decode( + toolCall( + name: "DeviceInteractionEndSession", + arguments: ["interactionSessionKey": "device-key"] + ) + ) == .continuesSession(key: "device-key", endsSession: true) + ) + } + + @Test func ignoresUnknownOrInvalidToolCalls() { + #expect( + DeviceInteractionToolCall.decode( + toolCall(name: "BuildProject", arguments: [:]) + ) == nil + ) + #expect( + DeviceInteractionToolCall.decode( + toolCall( + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": ""] + ) + ) == nil + ) + } + + @Test func decodesSessionKeyOnlyFromSuccessfulStructuredContent() throws { + let response = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": 1, + "result": [ + "content": [["type": "text", "text": "display-only"]], + "structuredContent": ["interactionSessionKey": "device-key"], + "isError": false, + ], + ]) + #expect(DeviceInteractionToolCall.successfulSessionKey(from: response) == "device-key") + + let toolError = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": 1, + "result": [ + "structuredContent": ["interactionSessionKey": "device-key"], + "isError": true, + ], + ]) + #expect(DeviceInteractionToolCall.successfulSessionKey(from: toolError) == nil) + + let textOnly = try JSONSerialization.data(withJSONObject: [ + "jsonrpc": "2.0", + "id": 1, + "result": [ + "content": [["type": "text", "text": "interactionSessionKey=device-key"]], + "isError": false, + ], + ]) + #expect(DeviceInteractionToolCall.successfulSessionKey(from: textOnly) == nil) + } + + @Test func ownsAffinityMembershipAndInvalidation() throws { + let authority = DeviceInteractionAffinityAuthority() + let route0 = ProcessRouteID(processID: 10, instanceGeneration: 1) + let proof0 = UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 0), + slotGeneration: 1 + ) + let proof1 = UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 1), + slotGeneration: 1 + ) + let affinity0 = DeviceInteractionAffinityAuthority.Affinity( + upstreamProof: proof0, + routeID: route0 + ) + let affinity1 = DeviceInteractionAffinityAuthority.Affinity( + upstreamProof: proof1 + ) + + authority.record(affinity0, for: "key-0") + authority.record(affinity1, for: "key-1") + #expect(authority.count() == 2) + #expect(authority.affinity(for: "key-0") == affinity0) + + authority.remove(routeIDs: [route0]) + #expect(authority.affinity(for: "key-0") == nil) + #expect(authority.affinity(for: "key-1") == affinity1) + + authority.remove(upstreamProofs: [proof1]) + #expect(authority.count() == 0) + + authority.record(affinity0, for: "key-0") + authority.remove(key: "key-0") + #expect(authority.count() == 0) + + authority.record(affinity0, for: "key-0") + authority.clear() + #expect(authority.count() == 0) + } + + private func toolCall(name: String, arguments: [String: Any]) -> [String: Any] { + [ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": [ + "name": name, + "arguments": arguments, + ], + ] + } +} diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift new file mode 100644 index 00000000..f29f6499 --- /dev/null +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift @@ -0,0 +1,399 @@ +import Foundation +import NIO +import Testing +@testable import XcodeMCPProxyRuntime + +@Suite(.serialized, .asyncTestCleanup) +struct DeviceInteractionRoutingTests { + @Test func continuationRoutesToTheExactUpstreamThatCreatedTheSession() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + let target = xcodeProcessTarget(processID: 701, xcodeVersion: "27.0") + let manager = RuntimeCoordinator( + config: makeConfig(requestTimeout: 5), + eventLoop: group.next(), + upstreams: [TestUpstreamClient(), TestUpstreamClient()], + xcodeProcessRoutes: [ + XcodeProcessRoute(target: target, upstreamIndices: [0, 1]) + ], + startImmediately: false + ) + defer { manager.shutdownAndWait() } + manager.markUpstreamInitialized(upstreamIndex: 0) + manager.markUpstreamInitialized(upstreamIndex: 1) + + let creatingLease = manager.operationLeaseForTest(upstreamIndex: 1) + manager.recordDeviceInteractionAffinityIfNeeded( + requestData: try requestData( + name: "DeviceInteractionStartSession", + arguments: ["sessionIdentifier": "Verify Flow"] + ), + responseData: try successfulToolResponse( + structuredContent: ["interactionSessionKey": "device-key"] + ), + operationLease: creatingLease + ) + + let decision = try #require( + manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 2, + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "device-key"] + ) + ) + ) + guard case .forwardAdmitted(let indices, let admission) = decision else { + Issue.record("expected affinity-bound routing") + return + } + #expect(indices == [1]) + #expect(admission.upstreamProofs == [creatingLease.proof]) + #expect(admission.route?.routeID == manager.xcodeProcessRoutes[0].id) + + #expect( + manager.recordXcodeWindowOwners( + from: try jsonValue([ + "structuredContent": [ + "message": "* tabIdentifier: raw-tab, workspacePath: /Work/App.xcworkspace" + ] + ]), + upstreamIndex: 1 + ) + ) + let proxyTabIdentifier = try #require( + manager.windowOwnershipAuthority.snapshot().identities.first?.proxyTabIdentifier + ) + let installRequest = toolsCallObject( + id: 5, + name: "DeviceInteractionInstallAndRun", + arguments: [ + "interactionSessionKey": "device-key", + "tabIdentifier": proxyTabIdentifier, + ] + ) + let installDecision = try #require( + manager.immediateToolRoutingDecision(for: installRequest) + ) + guard case .forwardAdmitted(let installIndices, let installAdmission) = installDecision else { + Issue.record("expected affinity-bound workspace routing") + return + } + #expect(installIndices == [1]) + #expect(installAdmission.window != nil) + let installData = try JSONSerialization.data(withJSONObject: installRequest) + let rewritten = manager.rewriteOwnerBoundRequest( + bodyData: installData, + parsedRequestJSON: installRequest, + operationLease: creatingLease, + admission: installAdmission + ) + let rewrittenObject = try #require( + JSONSerialization.jsonObject(with: rewritten.bodyData) as? [String: Any] + ) + let rewrittenParams = try #require(rewrittenObject["params"] as? [String: Any]) + let rewrittenArguments = try #require( + rewrittenParams["arguments"] as? [String: Any] + ) + #expect(rewrittenArguments["tabIdentifier"] as? String == "raw-tab") + } + + @Test func successfulEndRemovesTheRecordedAffinity() throws { + let fixture = try makeSingleRouteFixture(processID: 702) + defer { fixture.shutdown() } + + fixture.manager.recordDeviceInteractionAffinityIfNeeded( + requestData: try requestData( + name: "DeviceInteractionStartSession", + arguments: ["sessionIdentifier": "Verify Flow"] + ), + responseData: try successfulToolResponse( + structuredContent: ["interactionSessionKey": "device-key"] + ), + operationLease: fixture.operationLease + ) + fixture.manager.recordDeviceInteractionAffinityIfNeeded( + requestData: try requestData( + name: "DeviceInteractionEndSession", + arguments: ["interactionSessionKey": "device-key"] + ), + responseData: try successfulToolResponse(structuredContent: [:]), + operationLease: fixture.operationLease + ) + + let decision = try #require( + fixture.manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 3, + name: "DeviceInteractionEndSession", + arguments: ["interactionSessionKey": "device-key"] + ) + ) + ) + guard case .reject(let errors) = decision else { + Issue.record("ended session should no longer have affinity") + return + } + #expect(errors.map(\.message) == ["unknown device interaction session"]) + } + + @Test func upstreamReplacementInvalidatesAffinity() throws { + let fixture = try makeSingleRouteFixture(processID: 703) + defer { fixture.shutdown() } + + fixture.manager.recordDeviceInteractionAffinityIfNeeded( + requestData: try requestData( + name: "DeviceInteractionStartSession", + arguments: ["sessionIdentifier": "Verify Flow"] + ), + responseData: try successfulToolResponse( + structuredContent: ["interactionSessionKey": "device-key"] + ), + operationLease: fixture.operationLease + ) + let transition = fixture.manager.commitUpstreamTopologyMutation { + fixture.manager.upstreamTopology.replace( + fixture.operationLease.proof, + with: TestUpstreamClient() + ) + } + #expect(transition != nil) + #expect(fixture.manager.deviceInteractionAffinityAuthority.count() == 0) + } + + @Test func headlessUnboundPoolRoutesToExactCreatingUpstreamAndEvictsOnReplacement() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + var config = makeConfig(requestTimeout: 5) + config.xcodeMode = .headless + let manager = RuntimeCoordinator( + config: config, + eventLoop: group.next(), + upstreams: [TestUpstreamClient(), TestUpstreamClient()], + processRoutingEnabled: false, + startImmediately: false + ) + defer { manager.shutdownAndWait() } + manager.markUpstreamInitialized(upstreamIndex: 0) + manager.markUpstreamInitialized(upstreamIndex: 1) + + let creatingLease = manager.operationLeaseForTest(upstreamIndex: 1) + manager.recordDeviceInteractionAffinityIfNeeded( + requestData: try requestData( + name: "DeviceInteractionStartWorkspaceSession", + arguments: ["sessionIdentifier": "Verify Headless Flow"] + ), + responseData: try successfulToolResponse( + structuredContent: ["interactionSessionKey": "headless-device-key"] + ), + operationLease: creatingLease + ) + + let affinity = try #require( + manager.deviceInteractionAffinityAuthority.affinity(for: "headless-device-key") + ) + #expect(affinity.upstreamProof == creatingLease.proof) + #expect(affinity.routeID == nil) + let routed = try #require( + manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 6, + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "headless-device-key"] + ) + ) + ) + guard case .forwardAdmitted(let routedIndices, let routedAdmission) = routed else { + Issue.record("expected exact unbound affinity routing") + return + } + #expect(routedIndices == [1]) + #expect(routedAdmission.route == nil) + #expect(routedAdmission.upstreamProofs == [creatingLease.proof]) + + let transition = manager.commitUpstreamTopologyMutation { + manager.upstreamTopology.replace( + creatingLease.proof, + with: TestUpstreamClient() + ) + } + #expect(transition != nil) + #expect(manager.deviceInteractionAffinityAuthority.count() == 0) + + let afterReplacement = try #require( + manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 7, + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "headless-device-key"] + ) + ) + ) + guard case .reject(let errors) = afterReplacement else { + Issue.record("replaced unbound affinity should be rejected") + return + } + #expect(errors.map(\.message) == ["unknown device interaction session"]) + } + + @Test func headlessUnboundPoolRejectsUnknownSessionInsteadOfGuessing() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + var config = makeConfig(requestTimeout: 5) + config.xcodeMode = .headless + let manager = RuntimeCoordinator( + config: config, + eventLoop: group.next(), + upstreams: [TestUpstreamClient(), TestUpstreamClient()], + processRoutingEnabled: false, + startImmediately: false + ) + defer { manager.shutdownAndWait() } + + let decision = try #require( + manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 8, + name: "DeviceInteractionEndSession", + arguments: ["interactionSessionKey": "external-key"] + ) + ) + ) + guard case .reject(let errors) = decision else { + Issue.record("multi-upstream unbound runtime must not guess a session owner") + return + } + #expect(errors.map(\.message) == ["unknown device interaction session"]) + } + + @Test func exactUnboundDecisionRejectsReplacementGenerationBeforeSending() async throws { + let config = makeHTTPConfig() + let sessionManager = TestRuntimeCoordinator( + config: config, + upstreamResponder: { _, originalID in + try makeToolSuccessResponse(id: originalID, text: #"{"ok":true}"#) + } + ) + sessionManager.setInitialized(true) + sessionManager.setToolRoutingDecision( + .forwardAdmitted( + preferredUpstreamIndices: [1], + admission: RouteForwardingAdmission( + upstreamProofs: [ + UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 1), + slotGeneration: 0 + ) + ] + ) + ) + ) + sessionManager.setUsablePreferredUpstreamIndices([1]) + let server = try TestHTTPHandlerServer.start( + config: config, + sessionManager: sessionManager + ) + + do { + let (response, body) = try await postHTTPJSON( + url: server.url, + sessionID: "session-replaced-unbound-affinity", + payload: toolsCallPayload( + id: 9, + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "headless-device-key"] + ) + ) + + #expect(response.statusCode == 200) + let error = try #require(body["error"] as? [String: Any]) + #expect((error["code"] as? NSNumber)?.intValue == -32001) + #expect(error["message"] as? String == "upstream unavailable") + #expect(sessionManager.sentMethods().isEmpty) + } catch { + try? await server.shutdown() + throw error + } + try await server.shutdown() + } + + @Test func unboundRuntimeLeavesUnknownSessionHandlingToItsOnlyUpstream() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + var config = makeConfig(requestTimeout: 5) + config.xcodeMode = .headless + let manager = RuntimeCoordinator( + config: config, + eventLoop: group.next(), + upstreams: [TestUpstreamClient()], + processRoutingEnabled: false, + startImmediately: false + ) + defer { manager.shutdownAndWait() } + + let decision = try #require( + manager.immediateToolRoutingDecision( + for: toolsCallObject( + id: 4, + name: "DeviceInteractionSynthesize", + arguments: ["interactSessionKey": "external-key"] + ) + ) + ) + guard case .forward(let preferred) = decision else { + Issue.record("unbound runtime should preserve upstream handling") + return + } + #expect(preferred == nil) + } + + private struct Fixture { + let group: MultiThreadedEventLoopGroup + let manager: RuntimeCoordinator + let operationLease: UpstreamOperationLease + + func shutdown() { + manager.shutdownAndWait() + try? group.syncShutdownGracefully() + } + } + + private func makeSingleRouteFixture(processID: pid_t) throws -> Fixture { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let target = xcodeProcessTarget(processID: processID, xcodeVersion: "27.0") + let manager = RuntimeCoordinator( + config: makeConfig(requestTimeout: 5), + eventLoop: group.next(), + upstreams: [TestUpstreamClient()], + xcodeProcessRoutes: [ + XcodeProcessRoute(target: target, upstreamIndices: [0]) + ], + startImmediately: false + ) + manager.markUpstreamInitialized(upstreamIndex: 0) + return Fixture( + group: group, + manager: manager, + operationLease: manager.operationLeaseForTest(upstreamIndex: 0) + ) + } + + private func requestData(name: String, arguments: [String: Any]) throws -> Data { + try JSONSerialization.data( + withJSONObject: toolsCallObject(id: 1, name: name, arguments: arguments) + ) + } + + private func successfulToolResponse( + structuredContent: [String: Any] + ) throws -> Data { + try makeJSONRPCResponse( + id: 1, + result: [ + "content": [], + "structuredContent": structuredContent, + "isError": false, + ] + ) + } +} diff --git a/Tests/XcodeMCPProxyRuntimeTests/DisabledToolHTTPTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DisabledToolHTTPTests.swift index 14b4040d..6a7d2e22 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DisabledToolHTTPTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DisabledToolHTTPTests.swift @@ -33,7 +33,7 @@ extension HTTPHandlerTests { ) let resolved = try ProxyConfig.resolving(publicConfiguration) let config = HTTPTestConfiguration( - runtime: resolved.runtimeConfiguration, + runtime: resolved.runtimeConfiguration(xcodeMode: .gui), listenHost: resolved.listenHost, listenPort: resolved.listenPort, maxBodyBytes: resolved.maxBodyBytes diff --git a/Tests/XcodeMCPProxyRuntimeTests/HTTPHandlerTestSupport.swift b/Tests/XcodeMCPProxyRuntimeTests/HTTPHandlerTestSupport.swift index 70205912..8578d7d5 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/HTTPHandlerTestSupport.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/HTTPHandlerTestSupport.swift @@ -637,12 +637,20 @@ final class TestRuntimeCoordinator: RuntimeCoordinating { _ data: Data, operationLease: UpstreamOperationLease, ensureRunning: Bool, - admission _: RouteForwardingAdmission?, + admission: RouteForwardingAdmission?, requestSendCompletion: UpstreamRequestSendCompletion?, onRejected: @escaping @Sendable () -> Void ) -> Bool { let upstreamIndex = operationLease.upstreamIndex _ = ensureRunning + guard upstreamTopology.validate(operationLease), + admission.map({ + $0.proof(for: upstreamIndex) == operationLease.proof + }) ?? true else { + requestSendCompletion?.complete(.notSent) + onRejected() + return false + } let sendUpdate = state.withLockedValue { state -> (accepted: Bool, count: Int) in if state.rejectNextUpstreamSend { state.rejectNextUpstreamSend = false diff --git a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift index aeb67303..ef82e6f0 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift @@ -64,6 +64,26 @@ struct RuntimeCoordinatorProcessRoutingTests { #expect(plan.xcodeProcessRoutes.isEmpty) } + @Test func headlessStockBridgeBuildsOnlyUnboundUpstreamsDespiteGUITargets() throws { + try withEnvironmentVariables(["MCP_XCODE_PID": "5678"]) { + var config = makeConfig(requestTimeout: 0) + config.xcodeMode = .headless + + let plan = MCPBridgeRuntime.makeUpstreamPlan( + config: makeBridgeRuntimeConfig(config), + xcodeTargets: [xcodeProcessTarget(processID: 101)] + ) + + #expect(ProxyRuntime.supportsProcessBoundRouting(configuration: config) == false) + #expect(plan.upstreams.count == 1) + #expect(plan.xcodeProcessRoutes.isEmpty) + let upstream = try #require(plan.upstreams.first) + let environment = try upstreamEnvironment(from: upstream) + #expect(environment["MCP_XCODE_PID"] == nil) + #expect(environment["DEVELOPER_DIR"] == nil) + } + } + @Test func processRoutingWithoutInitialTargetsRunsReadinessAutoLaunch() async throws { let readiness = ReadinessFlag(isReady: false) let launchRecorder = XcodeLaunchRecorder() @@ -11978,8 +11998,9 @@ struct RuntimeCoordinatorWindowCatalogTests { return } #expect(preferred == [1]) - #expect(manager.processControlPlane.validate(admission.route)) - #expect(admission.window?.proof.route.routeID == admission.route.routeID) + let routeAdmission = try #require(admission.route) + #expect(manager.processControlPlane.validate(routeAdmission)) + #expect(admission.window?.proof.route.routeID == routeAdmission.routeID) } @Test func ownerHintRoutesBeforeProcessToolCatalogIsAvailable() async throws { @@ -13940,7 +13961,7 @@ struct RuntimeCoordinatorWindowRoutingTests { configurationFileURL: URL(fileURLWithPath: configPath), featurePolicy: .init(prewarmToolsList: false) ) - ).runtimeConfiguration + ).runtimeConfiguration(xcodeMode: .gui) let manager = RuntimeCoordinator(config: config, eventLoop: eventLoop, upstreams: [upstream]) defer { manager.shutdownAndWait() } @@ -13984,7 +14005,9 @@ struct RuntimeCoordinatorWindowRoutingTests { ), featurePolicy: .init(prewarmToolsList: false) ) - let config = try ProxyConfig.resolving(publicConfiguration).runtimeConfiguration + let config = try ProxyConfig.resolving(publicConfiguration).runtimeConfiguration( + xcodeMode: .gui + ) let group = borrowSharedTestEventLoopGroup() defer { shutdownAndWait(group) } @@ -14026,7 +14049,7 @@ struct RuntimeCoordinatorWindowRoutingTests { configurationFileURL: URL(fileURLWithPath: configPath), featurePolicy: .init(prewarmToolsList: false) ) - ).runtimeConfiguration + ).runtimeConfiguration(xcodeMode: .gui) let manager = RuntimeCoordinator(config: config, eventLoop: eventLoop, upstreams: [upstream]) defer { manager.shutdownAndWait() } @@ -14101,7 +14124,7 @@ struct RuntimeCoordinatorWindowRoutingTests { configurationFileURL: URL(fileURLWithPath: configPath), featurePolicy: .init(prewarmToolsList: false) ) - ).runtimeConfiguration + ).runtimeConfiguration(xcodeMode: .gui) let manager = RuntimeCoordinator( config: config, eventLoop: eventLoop, diff --git a/Tests/XcodeMCPProxyRuntimeTests/UpstreamReadinessTests.swift b/Tests/XcodeMCPProxyRuntimeTests/UpstreamReadinessTests.swift index d7d93363..4d3cd21d 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/UpstreamReadinessTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/UpstreamReadinessTests.swift @@ -33,6 +33,21 @@ struct UpstreamReadinessTests { #expect(XcrunArguments.isDefaultMCPBridgeInvocation(config: config) == false) } + @Test func headlessStockBridgeUsesAlwaysReadyGate() async { + var config = makeConfig(requestTimeout: 5) + config.xcodeMode = .headless + + let gate = UpstreamReadinessGate.liveDefault( + config: config, + clock: .liveValue, + processEventMonitor: NeverReadyXcodeProcessMonitor() + ) + + #expect(gate.isEnabled == false) + #expect(gate.launchIfUnavailable == nil) + #expect((await gate.snapshot()).isReady) + } + @Test func readinessChangeWaitDoesNotMissChangeBeforeRegistration() async throws { let readiness = ReadinessFlag(isReady: false) let snapshot = await readiness.snapshot() @@ -430,3 +445,18 @@ struct UpstreamReadinessTests { await sleepRecorder.resumeNext() } } + +private final class NeverReadyXcodeProcessMonitor: + XcodeProcessEventMonitoring, + @unchecked Sendable +{ + func start() {} + func setChangeHandler(_: @escaping @Sendable (String) -> Void) {} + func runningXcodeTargets() -> [XcodeProcessTarget] { [] } + func permissionDialogProcessIDs() -> [pid_t] { [] } + func readinessSnapshot() -> UpstreamReadinessSnapshot { + UpstreamReadinessSnapshot(isReady: false, generation: 0) + } + func waitForReadinessChange(after _: UInt64) async {} + func stop() {} +}