From 48e5e3b2234206dd670d35a8fe276686840b05b2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:13:30 +0900 Subject: [PATCH 01/16] docs(xcode): design headless MCP integration --- Docs/xcode-27-headless-mcp-design.md | 274 +++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 Docs/xcode-27-headless-mcp-design.md diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md new file mode 100644 index 00000000..d8ee14ac --- /dev/null +++ b/Docs/xcode-27-headless-mcp-design.md @@ -0,0 +1,274 @@ +# 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: pending + +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. + +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 routed GUI pools, the runtime records the returned key together with the +exact upstream topology proof that created it. Follow-up requests with either +spelling are admitted only to that current 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 GUI routes. + +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, + 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. +- [ ] Implement mode/status resolution and notice. +- [ ] Implement resolved runtime ownership and public/CLI surface. +- [ ] Implement device interaction affinity. +- [ ] Extend verifier and documentation. +- [ ] Run all validation and clean `codex-review`. +- [ ] Open a Ready PR to `main`. From 9f6fdd4aaf6b2a8a4706df9701f9ce4d9ca70eb1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:28:55 +0900 Subject: [PATCH 02/16] docs(xcode): record headless autostart probe --- Docs/xcode-27-headless-mcp-design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md index d8ee14ac..7de3c2b3 100644 --- a/Docs/xcode-27-headless-mcp-design.md +++ b/Docs/xcode-27-headless-mcp-design.md @@ -105,6 +105,9 @@ Xcode 27 developer directory in `DEVELOPER_DIR`: 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 @@ -266,6 +269,7 @@ that behavior is observed. - [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. - [ ] Implement mode/status resolution and notice. - [ ] Implement resolved runtime ownership and public/CLI surface. - [ ] Implement device interaction affinity. From bd16c91ede7215c3fc10c8f84180a3e20baf3eee Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:32:59 +0900 Subject: [PATCH 03/16] feat(proxy): model device interaction affinity --- .../DeviceInteractionAffinityAuthority.swift | 116 ++++++++++++++ ...iceInteractionAffinityAuthorityTests.swift | 148 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift create mode 100644 Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift diff --git a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift new file mode 100644 index 00000000..5ee21502 --- /dev/null +++ b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift @@ -0,0 +1,116 @@ +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 successfulSessionKey(from responseData: Data) -> String? { + guard let object = try? JSONRPC.Wire.object(fromData: responseData), + object["error"] == nil, + let result = object["result"] as? [String: Any], + result["isError"] as? Bool != true, + let structuredContent = result["structuredContent"] as? [String: Any], + let key = structuredContent["interactionSessionKey"] as? String, + key.isEmpty == false else { + return nil + } + return key + } + + 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) + } +} + +final class DeviceInteractionAffinityAuthority: Sendable { + struct Affinity: Equatable, Sendable { + let routeProof: ProcessControlPlaneAuthority.RouteProof + let upstreamProof: UpstreamTopologyProof + } + + 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 + routeIDs.contains(affinity.routeProof.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/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift new file mode 100644 index 00000000..17f2ff2d --- /dev/null +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift @@ -0,0 +1,148 @@ +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 route1 = ProcessRouteID(processID: 20, instanceGeneration: 1) + let proof0 = UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 0), + slotGeneration: 1 + ) + let proof1 = UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 1), + slotGeneration: 1 + ) + let affinity0 = DeviceInteractionAffinityAuthority.Affinity( + routeProof: .init(exposureEpoch: 1, routeID: route0), + upstreamProof: proof0 + ) + let affinity1 = DeviceInteractionAffinityAuthority.Affinity( + routeProof: .init(exposureEpoch: 1, routeID: route1), + 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, + ], + ] + } +} From 1366d9379692e3b8e9ce3009f2298ff1ddac2e52 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:52:13 +0900 Subject: [PATCH 04/16] fix(proxy): preserve device interaction route affinity --- Docs/xcode-27-headless-mcp-design.md | 7 +- ...ntMCPRequestExecutor+ForwardExecutor.swift | 5 + ...ntimeCoordinator+XcodeProcessRouting.swift | 117 +++++++++ .../Session/Runtime/RuntimeCoordinator.swift | 38 ++- .../DeviceInteractionAffinityAuthority.swift | 30 ++- .../DeviceInteractionRoutingTests.swift | 241 ++++++++++++++++++ 6 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md index 7de3c2b3..c67b74d6 100644 --- a/Docs/xcode-27-headless-mcp-design.md +++ b/Docs/xcode-27-headless-mcp-design.md @@ -203,8 +203,9 @@ Follow-up tools use two spellings: `interactionSessionKey` For routed GUI pools, the runtime records the returned key together with the -exact upstream topology proof that created it. Follow-up requests with either -spelling are admitted only to that current proof. Route replacement, +stable process-route identity and exact upstream topology proof that created +it. Follow-up requests obtain a current route admission for that identity and +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 GUI routes. @@ -272,7 +273,7 @@ that behavior is observed. - [x] Verify unbound `mcpbridge` starts Xcode Service on demand. - [ ] Implement mode/status resolution and notice. - [ ] Implement resolved runtime ownership and public/CLI surface. -- [ ] Implement device interaction affinity. +- [x] Implement device interaction affinity. - [ ] Extend verifier and documentation. - [ ] Run all validation and clean `codex-review`. - [ ] Open a Ready PR to `main`. 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/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift index e8c0925b..cfce6000 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift @@ -495,6 +495,12 @@ extension RuntimeCoordinator { let request = toolRoutingRequest(in: object) else { return .forward(preferredUpstreamIndex: nil) } + if let affinityDecision = deviceInteractionAffinityRoutingDecision( + for: object, + request: request + ) { + return affinityDecision + } if request.id != nil, request.toolName == "XcodeListWindows" { return .localXcodeListWindows } @@ -509,6 +515,117 @@ extension RuntimeCoordinator { return nil } + func recordDeviceInteractionAffinityIfNeeded( + requestData: Data, + responseData: Data, + operationLease: UpstreamOperationLease + ) { + guard processRoutingEnabled, + let call = DeviceInteractionToolCall.decode(requestData: requestData) else { + return + } + + switch call { + case .startsSession: + guard let key = DeviceInteractionToolCall.successfulSessionKey( + from: responseData + ), + upstreamTopology.validate(operationLease), + let route = xcodeProcessRoute( + forUpstreamIndex: operationLease.upstreamIndex + ), + let routeProof = processControlPlane.routeProof(routeID: route.id) else { + return + } + deviceInteractionAffinityAuthority.record( + .init( + routeID: routeProof.routeID, + upstreamProof: operationLease.proof + ), + 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 { + return .reject( + errors: deviceInteractionRoutingErrors( + id: request.id, + message: "unknown device interaction session" + ) + ) + } + guard let routeProof = processControlPlane.routeProof(routeID: affinity.routeID), + let routeAdmission = processControlPlane.admit(routeProof), + upstreamTopology.validate(affinity.upstreamProof) 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 == affinity.routeID.processID, + windowProof.route.routeID == affinity.routeID, + 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..0d90e1b5 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) @@ -1086,6 +1098,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } func debugReset() { + deviceInteractionAffinityAuthority.clear() let initializeReset = initializeManager.resetForDebug() initializeReset.timeout?.cancel() initializeReset.recoveryTimeout?.cancel() @@ -1118,6 +1131,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 +1187,7 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { } func cancelForDeinit() { + deviceInteractionAffinityAuthority.clear() let shutdownState = initializeManager.beginShutdown() shutdownState.timeout?.cancel() shutdownState.recoveryTimeout?.cancel() @@ -1207,6 +1222,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 { @@ -1237,21 +1255,37 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { func commitUpstreamTopologyMutation( _ mutation: () -> UpstreamTopologyAuthority.Transition ) -> UpstreamTopologyAuthority.Transition { - upstreamTopologyCommitLock.withLock { + let transition = upstreamTopologyCommitLock.withLock { let transition = mutation() publishUpstreamTopology(transition.snapshot) return transition } + removeDeviceInteractionAffinities(in: transition) + return transition } func commitUpstreamTopologyMutation( _ mutation: () -> UpstreamTopologyAuthority.Transition? ) -> UpstreamTopologyAuthority.Transition? { - upstreamTopologyCommitLock.withLock { + let transition: UpstreamTopologyAuthority.Transition? = upstreamTopologyCommitLock.withLock { guard let transition = mutation() else { return nil } publishUpstreamTopology(transition.snapshot) return transition } + if let transition { + 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 { diff --git a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift index 5ee21502..4c28ab1a 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift @@ -40,11 +40,15 @@ enum DeviceInteractionToolCall: Equatable, Sendable { } } + 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 object = try? JSONRPC.Wire.object(fromData: responseData), - object["error"] == nil, - let result = object["result"] as? [String: Any], - result["isError"] as? Bool != true, + guard let result = successfulResult(from: responseData), let structuredContent = result["structuredContent"] as? [String: Any], let key = structuredContent["interactionSessionKey"] as? String, key.isEmpty == false else { @@ -53,6 +57,10 @@ enum DeviceInteractionToolCall: Equatable, Sendable { return key } + static func isSuccessfulResponse(_ responseData: Data) -> Bool { + successfulResult(from: responseData) != nil + } + private static func continuation( arguments: Any?, keyName: String, @@ -65,11 +73,21 @@ enum DeviceInteractionToolCall: Equatable, Sendable { } 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 routeProof: ProcessControlPlaneAuthority.RouteProof + let routeID: ProcessRouteID let upstreamProof: UpstreamTopologyProof } @@ -92,7 +110,7 @@ final class DeviceInteractionAffinityAuthority: Sendable { guard routeIDs.isEmpty == false else { return } affinities.withLockedValue { affinities in affinities = affinities.filter { _, affinity in - routeIDs.contains(affinity.routeProof.routeID) == false + routeIDs.contains(affinity.routeID) == false } } } diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift new file mode 100644 index 00000000..46f4059c --- /dev/null +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift @@ -0,0 +1,241 @@ +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 unboundRuntimeLeavesUnknownSessionHandlingToItsOnlyUpstream() throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { try? group.syncShutdownGracefully() } + let manager = RuntimeCoordinator( + config: makeConfig(requestTimeout: 5), + 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, + ] + ) + } +} From 8669d0cbfa1dbae8e1d0be457cd870ad799c2dd5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:52:23 +0900 Subject: [PATCH 05/16] test(proxy): cover device affinity invalidation --- .../DeviceInteractionAffinityAuthorityTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift index 17f2ff2d..09636ddb 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift @@ -105,11 +105,11 @@ import Testing slotGeneration: 1 ) let affinity0 = DeviceInteractionAffinityAuthority.Affinity( - routeProof: .init(exposureEpoch: 1, routeID: route0), + routeID: route0, upstreamProof: proof0 ) let affinity1 = DeviceInteractionAffinityAuthority.Affinity( - routeProof: .init(exposureEpoch: 1, routeID: route1), + routeID: route1, upstreamProof: proof1 ) From 0300eb6b67d304cc911689785937cf947a33d014 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:55:13 +0900 Subject: [PATCH 06/16] Add Xcode 27 headless MCP mode --- README.md | 26 +- .../CLI/Server/ProxyServerCommand.swift | 4 + .../Internal/Configuration/ProxyConfig.swift | 63 +++- .../XcodeMCPServerStatusClient.swift | 244 +++++++++++++ Sources/XcodeMCPProxyKit/README.md | 16 +- .../XcodeMCPProxyServer+Launch.swift | 9 + .../XcodeMCPProxyServer+Startup.swift | 36 +- .../XcodeMCPProxyServer.swift | 126 ++++++- .../BridgeRuntime/MCPBridgeRuntime.swift | 8 +- .../ProxyRuntimeConfiguration.swift | 9 + ...figuration+ProxyRuntimeConfiguration.swift | 6 +- .../Session/Runtime/ProxyRuntimeAPI.swift | 18 +- .../Session/Runtime/RuntimeCoordinator.swift | 1 + .../Runtime/XcodeUpstreamReadiness.swift | 4 +- .../ProxyCLITests/CLIUsageContractTests.swift | 1 + Tests/ProxyCLITests/ServerCommandTests.swift | 31 ++ Tests/ProxyCLITests/ServerRunnerTests.swift | 10 + .../XcodeMCPProxyServerBuildInfoTests.swift | 35 ++ .../XcodeMCPProxyServerTests.swift | 153 ++++++++ .../XcodeMCPServerStatusClientTests.swift | 332 ++++++++++++++++++ .../PublicProductContractTests.swift | 5 +- .../DisabledToolHTTPTests.swift | 2 +- .../RuntimeCoordinatorTests.swift | 30 +- .../UpstreamReadinessTests.swift | 30 ++ 24 files changed, 1149 insertions(+), 50 deletions(-) create mode 100644 Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift create mode 100644 Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift diff --git a/README.md b/README.md index e149fff3..099d7686 100644 --- a/README.md +++ b/README.md @@ -59,12 +59,27 @@ 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. ### 2. Start the Proxy Server @@ -72,7 +87,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). @@ -128,6 +143,7 @@ xcode-mcp-proxy --help | `--upstream-processes n` | Number of upstream `mcpbridge` processes per running Xcode process when the default `xcrun mcpbridge` upstream is used. 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 +154,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..c5b2ec78 --- /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 + ) + } + guard discovery.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + else { + throw Failure.discoveryReturnedNoPath + } + + let status = try await run( + operation: "mcp-server status", + request: ProcessRequest( + label: "read-xcode-mcp-server-status", + executablePath: MCPBridgeInvocation.xcrunCommand, + arguments: ["mcp-server", "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..f186ba40 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 ) ) @@ -60,6 +61,19 @@ 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. + +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..a13f221c 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 @@ -284,6 +288,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 +306,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 +346,8 @@ extension XcodeMCPProxyServer { httpGateway: httpGateway, runtime: runtime, autoApprover: autoApprover, - endpoint: endpoint + endpoint: endpoint, + xcodeMode: modeResolution.xcodeMode ) } catch { autoApprover?.cancel() @@ -343,6 +357,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..a1fc627d 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. @@ -231,6 +244,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 +260,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 +271,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 +284,7 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { self.discovery = discovery self.approvalPolicy = approvalPolicy self.featurePolicy = featurePolicy + self.xcodeMode = xcodeMode } init(serverProxyConfig proxyConfig: ProxyConfig) { @@ -274,12 +293,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 +316,8 @@ public struct XcodeMCPProxyServerConfiguration: Equatable, Sendable { featurePolicy: FeaturePolicy( prewarmToolsList: proxyConfig.prewarmToolsList, refreshCodeIssuesMode: RefreshCodeIssuesMode(proxyConfig.refreshCodeIssuesMode) - ) + ), + xcodeMode: XcodeMode(proxyConfig.xcodeMode) ) } @@ -308,6 +333,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 +510,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 +532,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 +557,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 +565,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 +708,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 +728,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 +739,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 +772,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 +865,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 +886,7 @@ extension ProxyConfig { ProxyConfig.File.InitializeHandshakeOverride(initializeHandshake) ) } + try resolved.validateXcodeModeConfiguration() return resolved } } @@ -866,6 +934,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/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.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift index 0d90e1b5..945e3612 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift @@ -674,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 ) 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/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..e2e087dd 100644 --- a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift +++ b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift @@ -296,6 +296,159 @@ 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 startRejectsRepeatedStartsOnSameServerInstance() async throws { let autoApprover = RecordingAutoApprover() let upstream = RecordingUpstreamSlot() diff --git a/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift b/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift new file mode 100644 index 00000000..73d656f2 --- /dev/null +++ b/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift @@ -0,0 +1,332 @@ +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 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 == MCPBridgeInvocation.xcrunCommand) + #expect(recorded[1].arguments == ["mcp-server", "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/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/RuntimeCoordinatorTests.swift b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift index aeb67303..1ce0cc5b 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() @@ -13940,7 +13960,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 +14004,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 +14048,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 +14123,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() {} +} From 31499f3e4edfaa49ebb0f169850783e2f7b5fcd5 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:46:22 +0900 Subject: [PATCH 07/16] feat(verifier): exercise headless Xcode workspaces --- Docs/xcode-27-mcpbridge-tools.md | 94 ++- Sources/XcodeMCPProxyToolVerifier/README.md | 35 +- Sources/XcodeMCPProxyToolVerifier/main.swift | 677 ++++++++++++++++--- 3 files changed, 695 insertions(+), 111 deletions(-) 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/Sources/XcodeMCPProxyToolVerifier/README.md b/Sources/XcodeMCPProxyToolVerifier/README.md index d8657f57..ecb011d9 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 +``` + +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,8 @@ 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` and `--no-open-xcode` apply only to GUI verification. diff --git a/Sources/XcodeMCPProxyToolVerifier/main.swift b/Sources/XcodeMCPProxyToolVerifier/main.swift index f66f2daf..e3153664 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 GUI upstream mcpbridge processes per Xcode. 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,20 +351,51 @@ 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 { var lastError: (any Error)? for _ in 0..<90 { @@ -311,13 +458,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, ] + if options.xcodeMode == .gui { + arguments += [ + "--upstream-processes", "\(options.upstreamProcesses)", + "--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 +534,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 +557,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 +572,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 +712,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 +779,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 +801,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 +858,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 +1060,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 +1190,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 +1204,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 +1218,7 @@ private struct ToolVerificationRecord: Codable { let detail: String let arguments: [String: MCPJSONValue]? let rawResult: MCPJSONValue? + let rawProgress: [MCPJSONValue]? init( name: String, @@ -803,7 +1226,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 +1235,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 +1250,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 +1258,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 +1303,7 @@ private func isExternalPrerequisiteResult(toolName: String, detail: String) -> B let deviceTools: Set = [ "DeviceInteractionStartSession", + "DeviceInteractionStartWorkspaceSession", "DeviceInteractionInstallAndRun", "DeviceInteractionSynthesize", "DeviceInteractionEndSession", @@ -878,10 +1317,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 +1345,7 @@ private func orderedKnownToolNames() -> [String] { deduplicated( catalogToolNames() + bootstrapToolNames() + + projectConfigurationToolNames() + navigatorToolNames() + stringCatalogToolNames() + buildToolNames() @@ -901,6 +1358,7 @@ private func orderedKnownToolNames() -> [String] { private func catalogToolNames() -> [String] { [ "XcodeListWindows", + "XcodeListWorkspaces", "XcodeListSchemes", "XcodeListRunDestinations", "DocumentationSearch", @@ -910,10 +1368,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 +1452,7 @@ private func fieldReportToolNames() -> [String] { private func deviceInteractionToolNames() -> [String] { [ "DeviceInteractionStartSession", + "DeviceInteractionStartWorkspaceSession", "DeviceInteractionInstallAndRun", "DeviceInteractionSynthesize", "DeviceInteractionEndSession", From 067194a2040ba3dc4100ce8a45c619003845b972 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:15:03 +0900 Subject: [PATCH 08/16] Harden headless startup acquisition --- .../XcodeMCPServerStatusClient.swift | 8 +- .../XcodeMCPProxyServer+Startup.swift | 7 +- .../XcodeMCPProxyServerTests.swift | 94 +++++++++++++++++++ .../XcodeMCPServerStatusClientTests.swift | 19 +++- 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift b/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift index c5b2ec78..a50b22af 100644 --- a/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift +++ b/Sources/XcodeMCPProxyKit/Internal/XcodeService/XcodeMCPServerStatusClient.swift @@ -93,8 +93,8 @@ struct XcodeMCPServerStatusClient: Sendable { stderr: discovery.stderr ) } - guard discovery.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false - else { + let mcpServerPath = discovery.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard mcpServerPath.isEmpty == false else { throw Failure.discoveryReturnedNoPath } @@ -102,8 +102,8 @@ struct XcodeMCPServerStatusClient: Sendable { operation: "mcp-server status", request: ProcessRequest( label: "read-xcode-mcp-server-status", - executablePath: MCPBridgeInvocation.xcrunCommand, - arguments: ["mcp-server", "status", "--format", "json"], + executablePath: mcpServerPath, + arguments: ["status", "--format", "json"], input: nil, timeoutNanoseconds: Self.statusTimeoutNanoseconds ) diff --git a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift index a13f221c..af8bd318 100644 --- a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift +++ b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer+Startup.swift @@ -108,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 @@ -227,6 +231,7 @@ extension XcodeMCPProxyServer { phase = .stopped return } + startupTask.cancel() do { let acquired = try await startupTask.value self.startupTask = nil diff --git a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift index e2e087dd..e799208d 100644 --- a/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift +++ b/Tests/ProxyIntegrationTests/XcodeMCPProxyServerTests.swift @@ -449,6 +449,76 @@ struct XcodeMCPProxyServerTests { #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() @@ -885,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 index 73d656f2..a8b61814 100644 --- a/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift +++ b/Tests/ProxyIntegrationTests/XcodeMCPServerStatusClientTests.swift @@ -45,6 +45,18 @@ struct XcodeMCPServerStatusClientTests { #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" { @@ -145,8 +157,11 @@ struct XcodeMCPServerStatusClientTests { recorded[0].timeoutNanoseconds == XcodeMCPServerStatusClient.discoveryTimeoutNanoseconds ) - #expect(recorded[1].executablePath == MCPBridgeInvocation.xcrunCommand) - #expect(recorded[1].arguments == ["mcp-server", "status", "--format", "json"]) + #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 From b204d5778d8ab62e10c332811ab5c00db4069f5d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:32:15 +0900 Subject: [PATCH 09/16] fix(verifier): await proxy listener readiness --- Sources/XcodeMCPProxyToolVerifier/main.swift | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/Sources/XcodeMCPProxyToolVerifier/main.swift b/Sources/XcodeMCPProxyToolVerifier/main.swift index e3153664..eb232028 100644 --- a/Sources/XcodeMCPProxyToolVerifier/main.swift +++ b/Sources/XcodeMCPProxyToolVerifier/main.swift @@ -397,7 +397,26 @@ private struct ProxyToolVerifier { } private func connectToProxy(server: RunningProcess) async throws -> XcodeMCP { - var lastError: (any Error)? + try await waitForProxyListener(server: server) + return try await XcodeMCP( + configuration: .init( + transport: .streamableHTTP(endpoint: options.endpoint), + clientName: "XcodeMCPProxyToolVerifier", + clientVersion: "dev", + requestTimeout: .seconds(options.requestTimeoutSeconds) + ) + ) + } + + 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" for _ in 0..<90 { guard server.isRunning else { throw VerifierFailure( @@ -406,21 +425,18 @@ private struct ProxyToolVerifier { ) } do { - return try await XcodeMCP( - configuration: .init( - transport: .streamableHTTP(endpoint: options.endpoint), - clientName: "XcodeMCPProxyToolVerifier", - clientVersion: "dev", - requestTimeout: .seconds(options.requestTimeoutSeconds) - ) - ) + let (_, response) = try await session.data(for: request) + if response is HTTPURLResponse { + return + } + } catch is CancellationError { + throw CancellationError() } catch { - lastError = error - try await Task.sleep(for: .seconds(1)) + try await Task.sleep(for: .milliseconds(100)) } } throw VerifierFailure( - "proxy did not become ready at \(options.endpoint.absoluteString): \(lastError.map(errorDescription) ?? "unknown error")" + "proxy listener did not become ready at \(options.endpoint.absoluteString)" ) } From 8e125dff948308dd5db4a0c599431e488fca0e1e Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:37:54 +0900 Subject: [PATCH 10/16] docs(xcode): explain headless workspace approval --- README.md | 3 +++ Sources/XcodeMCPProxyKit/README.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 099d7686..b8210d07 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ 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 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 diff --git a/Sources/XcodeMCPProxyKit/README.md b/Sources/XcodeMCPProxyKit/README.md index f186ba40..5a814451 100644 --- a/Sources/XcodeMCPProxyKit/README.md +++ b/Sources/XcodeMCPProxyKit/README.md @@ -70,7 +70,9 @@ 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. +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`. From d42f81cde5f19989977d852564687f1408bb5734 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:49:52 +0900 Subject: [PATCH 11/16] fix(proxy): preserve unbound device session affinity --- Docs/xcode-27-headless-mcp-design.md | 18 ++- .../MCP/MCPForwardingService.swift | 14 ++ ...entMCPRequestExecutor+RefreshSupport.swift | 13 ++ .../Post/ClientMCPRequestExecutor.swift | 15 +- ...ntimeCoordinator+XcodeProcessRouting.swift | 80 ++++++--- .../Session/Runtime/RuntimeCoordinator.swift | 12 +- .../Session/Runtime/ToolRoutingDecision.swift | 3 + .../DeviceInteractionAffinityAuthority.swift | 13 +- ...iceInteractionAffinityAuthorityTests.swift | 6 +- .../DeviceInteractionRoutingTests.swift | 153 +++++++++++++++++- 10 files changed, 281 insertions(+), 46 deletions(-) diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md index c67b74d6..c5e506c6 100644 --- a/Docs/xcode-27-headless-mcp-design.md +++ b/Docs/xcode-27-headless-mcp-design.md @@ -202,13 +202,14 @@ Follow-up tools use two spellings: - `DeviceInteractionInstallAndRun` and `DeviceInteractionEndSession`: `interactionSessionKey` -For routed GUI pools, the runtime records the returned key together with the -stable process-route identity and exact upstream topology proof that created -it. Follow-up requests obtain a current route admission for that identity and -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 GUI routes. +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 @@ -259,7 +260,8 @@ that behavior is observed. - Startup-summary and exact multiline notice tests. - Public product contract compile test for `xcodeMode`. - Device-affinity owner and routing tests, including both key spellings, - replacement, retirement, end, and unknown keys. + 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. diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift index c440c445..41d9d5bd 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift @@ -201,9 +201,11 @@ struct MCPForwardingService: Sendable { let preferredUpstreamIndices: [Int]? let admission: RouteForwardingAdmission? + let requiredUpstreamProof: UpstreamTopologyProof? if let upstreamIndexOverride { preferredUpstreamIndices = [upstreamIndexOverride] admission = nil + requiredUpstreamProof = nil } else { switch await sessionManager.toolRoutingDecision( for: requestObject, @@ -212,12 +214,19 @@ struct MCPForwardingService: Sendable { case .forward(let resolvedUpstreamIndex): preferredUpstreamIndices = resolvedUpstreamIndex.map { [$0] } admission = nil + requiredUpstreamProof = nil + case .forwardExact(let upstreamProof): + preferredUpstreamIndices = [upstreamProof.slotID.rawValue] + admission = nil + requiredUpstreamProof = upstreamProof case .forwardAny(let resolvedUpstreamIndices): preferredUpstreamIndices = resolvedUpstreamIndices admission = nil + requiredUpstreamProof = nil case .forwardAdmitted(let resolvedUpstreamIndices, let resolvedAdmission): preferredUpstreamIndices = resolvedUpstreamIndices admission = resolvedAdmission + requiredUpstreamProof = nil case .localXcodeListWindows: return .unavailable case .reject: @@ -252,6 +261,11 @@ struct MCPForwardingService: Sendable { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { selectedOperationLease -> EventLoopFuture in + if let requiredUpstreamProof, + selectedOperationLease.proof != requiredUpstreamProof + { + return eventLoop.makeSucceededFuture(.upstreamUnavailable) + } guard internalCancellationHandle.activate( operationLease: selectedOperationLease ) else { diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift index 9ac904dc..52f290c2 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift @@ -49,6 +49,7 @@ extension ClientMCPRequestExecutor { ) let preferredUpstreamIndices: [Int]? let admission: RouteForwardingAdmission? + let requiredUpstreamProof: UpstreamTopologyProof? switch await sessionManager.toolRoutingDecision( for: requestObject, requestTimeoutOverride: requestTimeoutOverride @@ -56,12 +57,19 @@ extension ClientMCPRequestExecutor { case .forward(let resolvedUpstreamIndex): preferredUpstreamIndices = resolvedUpstreamIndex.map { [$0] } admission = nil + requiredUpstreamProof = nil + case .forwardExact(let upstreamProof): + preferredUpstreamIndices = [upstreamProof.slotID.rawValue] + admission = nil + requiredUpstreamProof = upstreamProof case .forwardAny(let resolvedUpstreamIndices): preferredUpstreamIndices = resolvedUpstreamIndices admission = nil + requiredUpstreamProof = nil case .forwardAdmitted(let resolvedUpstreamIndices, let resolvedAdmission): preferredUpstreamIndices = resolvedUpstreamIndices admission = resolvedAdmission + requiredUpstreamProof = nil case .localXcodeListWindows: return .upstreamUnavailable(responseID: responseID) case .reject: @@ -76,6 +84,11 @@ extension ClientMCPRequestExecutor { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { selectedOperationLease -> EventLoopFuture in + if let requiredUpstreamProof, + selectedOperationLease.proof != requiredUpstreamProof + { + return eventLoop.makeSucceededFuture(.upstreamUnavailable) + } if let cancellationHandle, cancellationHandle.activate(operationLease: selectedOperationLease) == false { diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift index 603c8cae..8caa3e2f 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift @@ -330,7 +330,8 @@ final class ClientMCPRequestExecutor: Sendable { } func forward( preferredUpstreamIndices: [Int]?, - admission: RouteForwardingAdmission? = nil + admission: RouteForwardingAdmission? = nil, + requiredUpstreamProof: UpstreamTopologyProof? = nil ) -> EventLoopFuture { let remainingTimeout = forwardingTimeout() if forwardingDeadline != nil, remainingTimeout == nil { @@ -342,6 +343,13 @@ final class ClientMCPRequestExecutor: Sendable { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { operationLease in + if let requiredUpstreamProof, + operationLease.proof != requiredUpstreamProof + { + return eventLoop.makeFailedFuture( + ProxyUpstreamRequestRuntime.Error.staleUpstreamTopology + ) + } guard cancellationHandle.activate(operationLease: operationLease) else { return eventLoop.makeFailedFuture(CancellationError()) } @@ -450,6 +458,11 @@ final class ClientMCPRequestExecutor: Sendable { return promise.futureResult case .forward(let preferredUpstreamIndex): return forward(preferredUpstreamIndices: preferredUpstreamIndex.map { [$0] }) + case .forwardExact(let upstreamProof): + return forward( + preferredUpstreamIndices: [upstreamProof.slotID.rawValue], + requiredUpstreamProof: upstreamProof + ) case .forwardAny(let preferredUpstreamIndices): return forward( preferredUpstreamIndices: preferredUpstreamIndices diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift index cfce6000..f3381ad9 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift @@ -488,9 +488,6 @@ 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) @@ -501,6 +498,9 @@ extension RuntimeCoordinator { ) { return affinityDecision } + guard processRoutingEnabled else { + return .forward(preferredUpstreamIndex: nil) + } if request.id != nil, request.toolName == "XcodeListWindows" { return .localXcodeListWindows } @@ -520,8 +520,7 @@ extension RuntimeCoordinator { responseData: Data, operationLease: UpstreamOperationLease ) { - guard processRoutingEnabled, - let call = DeviceInteractionToolCall.decode(requestData: requestData) else { + guard let call = DeviceInteractionToolCall.decode(requestData: requestData) else { return } @@ -529,21 +528,32 @@ extension RuntimeCoordinator { case .startsSession: guard let key = DeviceInteractionToolCall.successfulSessionKey( from: responseData - ), - upstreamTopology.validate(operationLease), - let route = xcodeProcessRoute( - forUpstreamIndex: operationLease.upstreamIndex - ), - let routeProof = processControlPlane.routeProof(routeID: route.id) else { + ) else { return } - deviceInteractionAffinityAuthority.record( - .init( - routeID: routeProof.routeID, - upstreamProof: operationLease.proof + let routeID: ProcessRouteID? + if processRoutingEnabled { + guard let route = xcodeProcessRoute( + forUpstreamIndex: operationLease.upstreamIndex ), - for: key - ) + 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 { @@ -563,6 +573,11 @@ extension RuntimeCoordinator { 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, @@ -570,9 +585,30 @@ extension RuntimeCoordinator { ) ) } - guard let routeProof = processControlPlane.routeProof(routeID: affinity.routeID), - let routeAdmission = processControlPlane.admit(routeProof), - upstreamTopology.validate(affinity.upstreamProof) else { + 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 .forwardExact(upstreamProof: affinity.upstreamProof) + } + guard processRoutingEnabled, + let routeProof = processControlPlane.routeProof(routeID: affinityRouteID), + let routeAdmission = processControlPlane.admit(routeProof) else { deviceInteractionAffinityAuthority.remove(key: key) return .reject( errors: deviceInteractionRoutingErrors( @@ -587,8 +623,8 @@ extension RuntimeCoordinator { guard case .resolved(let processID, _, let windowProof) = cachedOwnerResolution( for: request ), - processID == affinity.routeID.processID, - windowProof.route.routeID == affinity.routeID, + processID == affinityRouteID.processID, + windowProof.route.routeID == affinityRouteID, windowProof.windowEpoch == owners.epoch else { return .reject( errors: deviceInteractionRoutingErrors( diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift index 945e3612..496418f5 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator.swift @@ -1256,27 +1256,23 @@ final class RuntimeCoordinator: Sendable, RuntimeCoordinating { func commitUpstreamTopologyMutation( _ mutation: () -> UpstreamTopologyAuthority.Transition ) -> UpstreamTopologyAuthority.Transition { - let transition = upstreamTopologyCommitLock.withLock { + upstreamTopologyCommitLock.withLock { let transition = mutation() publishUpstreamTopology(transition.snapshot) + removeDeviceInteractionAffinities(in: transition) return transition } - removeDeviceInteractionAffinities(in: transition) - return transition } func commitUpstreamTopologyMutation( _ mutation: () -> UpstreamTopologyAuthority.Transition? ) -> UpstreamTopologyAuthority.Transition? { - let transition: UpstreamTopologyAuthority.Transition? = upstreamTopologyCommitLock.withLock { + upstreamTopologyCommitLock.withLock { guard let transition = mutation() else { return nil } publishUpstreamTopology(transition.snapshot) - return transition - } - if let transition { removeDeviceInteractionAffinities(in: transition) + return transition } - return transition } private func removeDeviceInteractionAffinities( diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift index 24291d16..ec2b0d56 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift @@ -3,6 +3,7 @@ import XcodeMCPKit enum ToolRoutingDecision: Sendable { case forward(preferredUpstreamIndex: Int?) + case forwardExact(upstreamProof: UpstreamTopologyProof) case forwardAny(preferredUpstreamIndices: [Int]) case forwardAdmitted( preferredUpstreamIndices: [Int], @@ -15,6 +16,8 @@ enum ToolRoutingDecision: Sendable { switch self { case .forward(let index): return index.map { [$0] } + case .forwardExact(let proof): + return [proof.slotID.rawValue] case .forwardAny(let indices), .forwardAdmitted(let indices, _): return indices case .localXcodeListWindows, .reject: diff --git a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift index 4c28ab1a..907733b2 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/XcodeFeatures/DeviceInteractionAffinityAuthority.swift @@ -87,8 +87,16 @@ enum DeviceInteractionToolCall: Equatable, Sendable { final class DeviceInteractionAffinityAuthority: Sendable { struct Affinity: Equatable, Sendable { - let routeID: ProcessRouteID let upstreamProof: UpstreamTopologyProof + let routeID: ProcessRouteID? + + init( + upstreamProof: UpstreamTopologyProof, + routeID: ProcessRouteID? = nil + ) { + self.upstreamProof = upstreamProof + self.routeID = routeID + } } private let affinities = NIOLockedValueBox<[String: Affinity]>([:]) @@ -110,7 +118,8 @@ final class DeviceInteractionAffinityAuthority: Sendable { guard routeIDs.isEmpty == false else { return } affinities.withLockedValue { affinities in affinities = affinities.filter { _, affinity in - routeIDs.contains(affinity.routeID) == false + guard let routeID = affinity.routeID else { return true } + return routeIDs.contains(routeID) == false } } } diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift index 09636ddb..b6a09d4a 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionAffinityAuthorityTests.swift @@ -95,7 +95,6 @@ import Testing @Test func ownsAffinityMembershipAndInvalidation() throws { let authority = DeviceInteractionAffinityAuthority() let route0 = ProcessRouteID(processID: 10, instanceGeneration: 1) - let route1 = ProcessRouteID(processID: 20, instanceGeneration: 1) let proof0 = UpstreamTopologyProof( slotID: UpstreamSlotID(rawValue: 0), slotGeneration: 1 @@ -105,11 +104,10 @@ import Testing slotGeneration: 1 ) let affinity0 = DeviceInteractionAffinityAuthority.Affinity( - routeID: route0, - upstreamProof: proof0 + upstreamProof: proof0, + routeID: route0 ) let affinity1 = DeviceInteractionAffinityAuthority.Affinity( - routeID: route1, upstreamProof: proof1 ) diff --git a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift index 46f4059c..64f8d24e 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift @@ -161,11 +161,162 @@ struct DeviceInteractionRoutingTests { #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 .forwardExact(let routedProof) = routed else { + Issue.record("expected exact unbound affinity routing") + return + } + #expect(routedProof == 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( + .forwardExact( + upstreamProof: 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: makeConfig(requestTimeout: 5), + config: config, eventLoop: group.next(), upstreams: [TestUpstreamClient()], processRoutingEnabled: false, From eb5ea0b244a4897bc70c73dd666a00e4544dec81 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:51:48 +0900 Subject: [PATCH 12/16] test(verifier): exercise pooled headless upstreams --- Sources/XcodeMCPProxyToolVerifier/README.md | 6 ++++-- Sources/XcodeMCPProxyToolVerifier/main.swift | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/XcodeMCPProxyToolVerifier/README.md b/Sources/XcodeMCPProxyToolVerifier/README.md index ecb011d9..514567e8 100644 --- a/Sources/XcodeMCPProxyToolVerifier/README.md +++ b/Sources/XcodeMCPProxyToolVerifier/README.md @@ -11,7 +11,7 @@ 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 +swift run xcode-mcp-proxy-tool-verifier --xcode-mode headless --upstream-processes 2 ``` Headless access must already be enabled. The verifier does not run @@ -57,4 +57,6 @@ swift run xcode-mcp-proxy-tool-verifier --xcode-mode headless --request-timeout swift run xcode-mcp-proxy-tool-verifier --keep-server ``` -`--upstream-processes` and `--no-open-xcode` apply only to GUI verification. +`--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 eb232028..5e6356de 100644 --- a/Sources/XcodeMCPProxyToolVerifier/main.swift +++ b/Sources/XcodeMCPProxyToolVerifier/main.swift @@ -78,7 +78,7 @@ 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 GUI 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 Git-ignored verifier output directory. Default: ProxyToolVerifierOutput --xcode-mode gui|headless Xcode runtime to verify. Default: gui @@ -478,10 +478,10 @@ private struct ProxyToolVerifier { "--listen", "\(options.host):\(options.port)", "--request-timeout", "\(options.requestTimeoutSeconds)", "--xcode-mode", options.xcodeMode.rawValue, + "--upstream-processes", "\(options.upstreamProcesses)", ] if options.xcodeMode == .gui { arguments += [ - "--upstream-processes", "\(options.upstreamProcesses)", "--auto-approve", "--refresh-code-issues-mode", "proxy", ] From 4caf2d29ecf65f3a3b4734e3ff4e11fca7864e0d Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:16:01 +0900 Subject: [PATCH 13/16] docs(xcode): clarify headless bridge pooling --- Docs/xcode-27-headless-mcp-design.md | 8 ++++---- README.md | 2 +- Sources/XcodeMCPProxyKit/README.md | 2 ++ Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift | 4 ++++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md index c5e506c6..07ef6407 100644 --- a/Docs/xcode-27-headless-mcp-design.md +++ b/Docs/xcode-27-headless-mcp-design.md @@ -6,7 +6,7 @@ - Baseline: `b251cecfa059ce7b5f0a9c9a8b7f9480e390edb3` - Verified Xcode: 27.0 build `27A5252f` - Verified Xcode MCP server: `xcode-tools` version `25295.11` -- Implementation: pending +- Implementation: complete; validation in progress 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. @@ -273,9 +273,9 @@ that behavior is observed. - [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. -- [ ] Implement mode/status resolution and notice. -- [ ] Implement resolved runtime ownership and public/CLI surface. +- [x] Implement mode/status resolution and notice. +- [x] Implement resolved runtime ownership and public/CLI surface. - [x] Implement device interaction affinity. -- [ ] Extend verifier and documentation. +- [x] Extend verifier and documentation. - [ ] Run all validation and clean `codex-review`. - [ ] Open a Ready PR to `main`. diff --git a/README.md b/README.md index b8210d07..15e4fbc7 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ 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. | diff --git a/Sources/XcodeMCPProxyKit/README.md b/Sources/XcodeMCPProxyKit/README.md index 5a814451..fdafbbd5 100644 --- a/Sources/XcodeMCPProxyKit/README.md +++ b/Sources/XcodeMCPProxyKit/README.md @@ -52,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 diff --git a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift index a1fc627d..0c1116c1 100644 --- a/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift +++ b/Sources/XcodeMCPProxyKit/XcodeMCPProxyServer.swift @@ -47,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) From dda5b3cabc7c30b118207836bc85a8f9a07aaca4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:12:49 +0900 Subject: [PATCH 14/16] refactor(proxy): centralize forwarding admission --- .../MCP/MCPForwardingService.swift | 14 ------- ...entMCPRequestExecutor+RefreshSupport.swift | 13 ------ .../Post/ClientMCPRequestExecutor.swift | 15 +------ .../RuntimeCoordinator+UpstreamRouting.swift | 40 ++++++++++++------- ...ntimeCoordinator+XcodeProcessRouting.swift | 7 +++- .../Session/Runtime/ToolRoutingDecision.swift | 11 +++-- .../DeviceInteractionRoutingTests.swift | 21 ++++++---- .../HTTPHandlerTestSupport.swift | 10 ++++- .../RuntimeCoordinatorTests.swift | 5 ++- 9 files changed, 66 insertions(+), 70 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift index 41d9d5bd..c440c445 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/MCP/MCPForwardingService.swift @@ -201,11 +201,9 @@ struct MCPForwardingService: Sendable { let preferredUpstreamIndices: [Int]? let admission: RouteForwardingAdmission? - let requiredUpstreamProof: UpstreamTopologyProof? if let upstreamIndexOverride { preferredUpstreamIndices = [upstreamIndexOverride] admission = nil - requiredUpstreamProof = nil } else { switch await sessionManager.toolRoutingDecision( for: requestObject, @@ -214,19 +212,12 @@ struct MCPForwardingService: Sendable { case .forward(let resolvedUpstreamIndex): preferredUpstreamIndices = resolvedUpstreamIndex.map { [$0] } admission = nil - requiredUpstreamProof = nil - case .forwardExact(let upstreamProof): - preferredUpstreamIndices = [upstreamProof.slotID.rawValue] - admission = nil - requiredUpstreamProof = upstreamProof case .forwardAny(let resolvedUpstreamIndices): preferredUpstreamIndices = resolvedUpstreamIndices admission = nil - requiredUpstreamProof = nil case .forwardAdmitted(let resolvedUpstreamIndices, let resolvedAdmission): preferredUpstreamIndices = resolvedUpstreamIndices admission = resolvedAdmission - requiredUpstreamProof = nil case .localXcodeListWindows: return .unavailable case .reject: @@ -261,11 +252,6 @@ struct MCPForwardingService: Sendable { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { selectedOperationLease -> EventLoopFuture in - if let requiredUpstreamProof, - selectedOperationLease.proof != requiredUpstreamProof - { - return eventLoop.makeSucceededFuture(.upstreamUnavailable) - } guard internalCancellationHandle.activate( operationLease: selectedOperationLease ) else { diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift index 52f290c2..9ac904dc 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor+RefreshSupport.swift @@ -49,7 +49,6 @@ extension ClientMCPRequestExecutor { ) let preferredUpstreamIndices: [Int]? let admission: RouteForwardingAdmission? - let requiredUpstreamProof: UpstreamTopologyProof? switch await sessionManager.toolRoutingDecision( for: requestObject, requestTimeoutOverride: requestTimeoutOverride @@ -57,19 +56,12 @@ extension ClientMCPRequestExecutor { case .forward(let resolvedUpstreamIndex): preferredUpstreamIndices = resolvedUpstreamIndex.map { [$0] } admission = nil - requiredUpstreamProof = nil - case .forwardExact(let upstreamProof): - preferredUpstreamIndices = [upstreamProof.slotID.rawValue] - admission = nil - requiredUpstreamProof = upstreamProof case .forwardAny(let resolvedUpstreamIndices): preferredUpstreamIndices = resolvedUpstreamIndices admission = nil - requiredUpstreamProof = nil case .forwardAdmitted(let resolvedUpstreamIndices, let resolvedAdmission): preferredUpstreamIndices = resolvedUpstreamIndices admission = resolvedAdmission - requiredUpstreamProof = nil case .localXcodeListWindows: return .upstreamUnavailable(responseID: responseID) case .reject: @@ -84,11 +76,6 @@ extension ClientMCPRequestExecutor { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { selectedOperationLease -> EventLoopFuture in - if let requiredUpstreamProof, - selectedOperationLease.proof != requiredUpstreamProof - { - return eventLoop.makeSucceededFuture(.upstreamUnavailable) - } if let cancellationHandle, cancellationHandle.activate(operationLease: selectedOperationLease) == false { diff --git a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift index 8caa3e2f..603c8cae 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/ClientRequestExecution/Post/ClientMCPRequestExecutor.swift @@ -330,8 +330,7 @@ final class ClientMCPRequestExecutor: Sendable { } func forward( preferredUpstreamIndices: [Int]?, - admission: RouteForwardingAdmission? = nil, - requiredUpstreamProof: UpstreamTopologyProof? = nil + admission: RouteForwardingAdmission? = nil ) -> EventLoopFuture { let remainingTimeout = forwardingTimeout() if forwardingDeadline != nil, remainingTimeout == nil { @@ -343,13 +342,6 @@ final class ClientMCPRequestExecutor: Sendable { on: eventLoop, preferredUpstreamIndices: preferredUpstreamIndices ) { operationLease in - if let requiredUpstreamProof, - operationLease.proof != requiredUpstreamProof - { - return eventLoop.makeFailedFuture( - ProxyUpstreamRequestRuntime.Error.staleUpstreamTopology - ) - } guard cancellationHandle.activate(operationLease: operationLease) else { return eventLoop.makeFailedFuture(CancellationError()) } @@ -458,11 +450,6 @@ final class ClientMCPRequestExecutor: Sendable { return promise.futureResult case .forward(let preferredUpstreamIndex): return forward(preferredUpstreamIndices: preferredUpstreamIndex.map { [$0] }) - case .forwardExact(let upstreamProof): - return forward( - preferredUpstreamIndices: [upstreamProof.slotID.rawValue], - requiredUpstreamProof: upstreamProof - ) case .forwardAny(let preferredUpstreamIndices): return forward( preferredUpstreamIndices: preferredUpstreamIndices 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 f3381ad9..49ea926a 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/RuntimeCoordinator+XcodeProcessRouting.swift @@ -604,7 +604,12 @@ extension RuntimeCoordinator { ) ) } - return .forwardExact(upstreamProof: affinity.upstreamProof) + return .forwardAdmitted( + preferredUpstreamIndices: [affinity.upstreamProof.slotID.rawValue], + admission: RouteForwardingAdmission( + upstreamProofs: [affinity.upstreamProof] + ) + ) } guard processRoutingEnabled, let routeProof = processControlPlane.routeProof(routeID: affinityRouteID), diff --git a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift index ec2b0d56..1f5f8887 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/Runtime/ToolRoutingDecision.swift @@ -3,7 +3,6 @@ import XcodeMCPKit enum ToolRoutingDecision: Sendable { case forward(preferredUpstreamIndex: Int?) - case forwardExact(upstreamProof: UpstreamTopologyProof) case forwardAny(preferredUpstreamIndices: [Int]) case forwardAdmitted( preferredUpstreamIndices: [Int], @@ -16,8 +15,6 @@ enum ToolRoutingDecision: Sendable { switch self { case .forward(let index): return index.map { [$0] } - case .forwardExact(let proof): - return [proof.slotID.rawValue] case .forwardAny(let indices), .forwardAdmitted(let indices, _): return indices case .localXcodeListWindows, .reject: @@ -27,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/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift index 64f8d24e..f29f6499 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DeviceInteractionRoutingTests.swift @@ -49,7 +49,7 @@ struct DeviceInteractionRoutingTests { } #expect(indices == [1]) #expect(admission.upstreamProofs == [creatingLease.proof]) - #expect(admission.route.routeID == manager.xcodeProcessRoutes[0].id) + #expect(admission.route?.routeID == manager.xcodeProcessRoutes[0].id) #expect( manager.recordXcodeWindowOwners( @@ -203,11 +203,13 @@ struct DeviceInteractionRoutingTests { ) ) ) - guard case .forwardExact(let routedProof) = routed else { + guard case .forwardAdmitted(let routedIndices, let routedAdmission) = routed else { Issue.record("expected exact unbound affinity routing") return } - #expect(routedProof == creatingLease.proof) + #expect(routedIndices == [1]) + #expect(routedAdmission.route == nil) + #expect(routedAdmission.upstreamProofs == [creatingLease.proof]) let transition = manager.commitUpstreamTopologyMutation { manager.upstreamTopology.replace( @@ -274,10 +276,15 @@ struct DeviceInteractionRoutingTests { ) sessionManager.setInitialized(true) sessionManager.setToolRoutingDecision( - .forwardExact( - upstreamProof: UpstreamTopologyProof( - slotID: UpstreamSlotID(rawValue: 1), - slotGeneration: 0 + .forwardAdmitted( + preferredUpstreamIndices: [1], + admission: RouteForwardingAdmission( + upstreamProofs: [ + UpstreamTopologyProof( + slotID: UpstreamSlotID(rawValue: 1), + slotGeneration: 0 + ) + ] ) ) ) 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 1ce0cc5b..ef82e6f0 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTests.swift @@ -11998,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 { From 133572b1c6f0ddf7b33ff528f5cfaf0f0a0832f4 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:42:43 +0900 Subject: [PATCH 15/16] docs(xcode): record validation status --- Docs/xcode-27-headless-mcp-design.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Docs/xcode-27-headless-mcp-design.md b/Docs/xcode-27-headless-mcp-design.md index 07ef6407..7627e075 100644 --- a/Docs/xcode-27-headless-mcp-design.md +++ b/Docs/xcode-27-headless-mcp-design.md @@ -6,7 +6,8 @@ - Baseline: `b251cecfa059ce7b5f0a9c9a8b7f9480e390edb3` - Verified Xcode: 27.0 build `27A5252f` - Verified Xcode MCP server: `xcode-tools` version `25295.11` -- Implementation: complete; validation in progress +- 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. @@ -277,5 +278,6 @@ that behavior is observed. - [x] Implement resolved runtime ownership and public/CLI surface. - [x] Implement device interaction affinity. - [x] Extend verifier and documentation. -- [ ] Run all validation and clean `codex-review`. -- [ ] Open a Ready PR to `main`. +- [x] Run automated validation and clean `codex-review`. +- [ ] Complete post-approval live workspace open/list/close and progress + verification. From fc03c063d6c6b0f2081bc409c2cc2b022c53d6fe Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:59:00 +0900 Subject: [PATCH 16/16] fix(verifier): await full proxy readiness --- Sources/XcodeMCPProxyToolVerifier/main.swift | 59 ++++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/Sources/XcodeMCPProxyToolVerifier/main.swift b/Sources/XcodeMCPProxyToolVerifier/main.swift index 5e6356de..76676a99 100644 --- a/Sources/XcodeMCPProxyToolVerifier/main.swift +++ b/Sources/XcodeMCPProxyToolVerifier/main.swift @@ -398,13 +398,39 @@ private struct ProxyToolVerifier { private func connectToProxy(server: RunningProcess) async throws -> XcodeMCP { try await waitForProxyListener(server: server) - return try await XcodeMCP( - configuration: .init( - transport: .streamableHTTP(endpoint: options.endpoint), - clientName: "XcodeMCPProxyToolVerifier", - clientVersion: "dev", - requestTimeout: .seconds(options.requestTimeoutSeconds) - ) + + var lastError: (any Error)? + for attempt in 0..<90 { + guard server.isRunning else { + throw VerifierFailure( + "debug proxy server exited before MCP initialization with status " + + "\(server.terminationStatus)" + ) + } + do { + return try await XcodeMCP( + configuration: .init( + transport: .streamableHTTP(endpoint: options.endpoint), + clientName: "XcodeMCPProxyToolVerifier", + clientVersion: "dev", + requestTimeout: .seconds(options.requestTimeoutSeconds) + ) + ) + } catch { + try Task.checkCancellation() + guard isRetryableMCPInitializationError(error) else { + throw error + } + lastError = error + if attempt < 89 { + try await Task.sleep(for: .seconds(1)) + } + } + } + throw VerifierFailure( + "proxy did not complete MCP initialization at " + + "\(options.endpoint.absoluteString): " + + "\(lastError.map(errorDescription) ?? "unknown error")" ) } @@ -417,7 +443,9 @@ private struct ProxyToolVerifier { var request = URLRequest(url: options.endpoint) request.httpMethod = "HEAD" - for _ in 0..<90 { + 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 " @@ -429,9 +457,8 @@ private struct ProxyToolVerifier { if response is HTTPURLResponse { return } - } catch is CancellationError { - throw CancellationError() } catch { + try Task.checkCancellation() try await Task.sleep(for: .milliseconds(100)) } } @@ -440,6 +467,18 @@ private struct ProxyToolVerifier { ) } + 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,