Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion Docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ If it fails:
- Confirm `xcrun mcpbridge -h` works in Terminal.

## `MCP client ... timed out`
Ensure the proxy server is running. Increase `startup_timeout_sec` in the client config if needed.
Ensure the proxy server is running. Before increasing any timeout, confirm that
Xcode MCP access is enabled as described in
[Set Up Your MCP Client](../README.md#1-enable-xcode-mcp-access). If the proxy
logs `route_activation_timeout`, or `bridge_pool_attach_verification_completed`
with `success=false reason=timeout`, follow the dedicated section below.
Increase `startup_timeout_sec` only when setup is correct and the upstream is
still starting too slowly.

If you see an error like:

Expand All @@ -20,6 +26,34 @@ it’s usually because the upstream (`xcrun mcpbridge` / Xcode) was slow on the
- The tool list cache is **not persisted to disk**. It survives repeated Codex restarts as long as the proxy server stays running.
- `tools/list` is intentionally treated as stable for the lifetime of the proxy process (no background refresh), to avoid upstream churn and surprise Xcode permission dialogs.

## Xcode tools are unavailable
`route_activation_timeout`, or `bridge_pool_attach_verification_completed` with
`success=false reason=timeout`, means `mcpbridge` initialized but Xcode did not
return a usable `tools/list` response before the route or bridge-attachment
deadline.

First, open your project in Xcode, choose **Xcode > Settings > Intelligence**,
and turn on **Allow external agents to use Xcode tools** under
**Model Context Protocol**. This global switch is required by
[Xcode's external-agent setup][apple-xcode-mcp-access].

`--auto-approve` only handles the per-connection **Allow** dialog; it does not
enable the global Xcode setting. If the setting is already on:

- Approve any pending Xcode connection dialog.
- When using `--auto-approve`, allow the app that launched the proxy (for
example, Terminal or iTerm) in
**System Settings > Privacy & Security > Accessibility**.
- Wait for the proxy's automatic retry. A recovered route logs
`route_activation_cataloged`; a recovered secondary bridge logs
`bridge_pool_attach_verification_completed` with `success=true`.

When Xcode does not send a JSON-RPC response for `tools/list`, the proxy cannot
recover Xcode's internal error from the stdio transport. The first timeout
therefore prints an **Xcode tools are unavailable** warning with recovery steps
and continues retrying automatically. Later retries keep the structured event
but do not repeat the warning.

## Streamable HTTP client cannot connect
- Ensure `xcode-mcp-proxy-server` is running.
- Confirm the URL is correct (default: `http://localhost:8765/mcp`).
Expand Down Expand Up @@ -90,3 +124,5 @@ Ensure the client is using the server-issued `MCP-Session-Id`. Initialize reques

## `protocol version required` / `protocol version mismatch`
The proxy only accepts `MCP-Protocol-Version: 2025-06-18` after initialize. Reinitialize the client session if it cached an older protocol version or omitted the header.

[apple-xcode-mcp-access]: https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,32 @@ source ~/.zshrc

## Set Up Your MCP Client

### 1. Start the Proxy Server
### 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**.
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.

### 2. Start the Proxy Server

```bash
xcode-mcp-proxy-server --auto-approve
```

`--auto-approve` clicks the Xcode **Allow** button automatically. It requires macOS Accessibility permission.
`--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).

Without Accessibility permission, omit `--auto-approve` and click **Allow** yourself:

```bash
xcode-mcp-proxy-server
```

### 2. Register the Client
### 3. Register the Client

Replace `xcrun mcpbridge` with the proxy endpoint.

Expand Down Expand Up @@ -234,3 +245,5 @@ Edit the draft release notes, then publish the release manually.
## License

[LICENSE](LICENSE)

[apple-xcode-mcp-access]: https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,16 @@ struct ProcessBridgeRecovery: Sendable, Hashable {
var upstreamID: UpstreamSlotID { reservation.upstreamID }
}

enum ProcessBridgeRecoveryFailure: Sendable, Equatable {
case toolsListTimeout
case other
}

struct ProcessBridgeRecoveryRetry: Sendable {
let reservation: ProcessBridgePoolRecovery
let delay: TimeAmount
let consecutiveFailureCount: Int
let shouldLogToolsUnavailableWarning: Bool
}

struct ProcessControlPlaneTransition: Sendable {
Expand Down Expand Up @@ -306,13 +313,14 @@ final class ProcessControlPlaneAuthority: Sendable {
case retryRequired(
transition: ProcessControlPlaneTransition,
retry: Retry,
catalogLease: CatalogLease
catalogLease: CatalogLease,
timeoutCount: Int
)

var transition: ProcessControlPlaneTransition {
switch self {
case .loadTimedOut(let transition),
.retryRequired(let transition, _, _):
.retryRequired(let transition, _, _, _):
return transition
}
}
Expand Down Expand Up @@ -406,6 +414,7 @@ final class ProcessControlPlaneAuthority: Sendable {
var readinessToken: UpstreamReadinessWaiterToken?
var retryTimeout: RuntimeScheduledTimeout?
var retryKind: RetryKind?
var catalogTimeoutCount: Int
var nextLoadID: Int
var loads: [CatalogLoadID: Load]

Expand Down Expand Up @@ -569,6 +578,7 @@ final class ProcessControlPlaneAuthority: Sendable {
var phase: Phase = .idle
var generation: UInt64 = 0
var consecutiveFailureCount = 0
var didLogToolsUnavailableWarning = false

mutating func reset(pendingUpstreamIDs: Set<UpstreamSlotID>)
-> [ProcessControlPlaneEffect]
Expand All @@ -583,6 +593,7 @@ final class ProcessControlPlaneAuthority: Sendable {
self.pendingUpstreamIDs = pendingUpstreamIDs
phase = .idle
consecutiveFailureCount = 0
didLogToolsUnavailableWarning = false
return effects
}
}
Expand Down Expand Up @@ -693,6 +704,7 @@ final class ProcessControlPlaneAuthority: Sendable {
let previousOwner = owner
owner.bridgeRecovery.phase = .idle
owner.bridgeRecovery.consecutiveFailureCount = 0
owner.bridgeRecovery.didLogToolsUnavailableWarning = false
let effects = Self.takeBridgePoolRecoveryEffects(owner: &owner)
state.recordsByKey[key] = owner
guard commit() else {
Expand All @@ -718,13 +730,15 @@ final class ProcessControlPlaneAuthority: Sendable {
}

func prepareBridgeRecoveryRetry(
_ recovery: ProcessBridgePoolRecovery
_ recovery: ProcessBridgePoolRecovery,
failure: ProcessBridgeRecoveryFailure
) -> ProcessBridgeRecoveryRetry? {
state.withLockedValue { state in
guard let key = Self.key(routeID: recovery.routeID, in: state),
var owner = state.recordsByKey[key],
let retry = Self.prepareBridgeRecoveryRetry(
recovery,
failure: failure,
owner: &owner
) else { return nil }
state.recordsByKey[key] = owner
Expand Down Expand Up @@ -1297,6 +1311,7 @@ final class ProcessControlPlaneAuthority: Sendable {
readinessToken: readinessToken,
retryTimeout: nil,
retryKind: nil,
catalogTimeoutCount: 0,
nextLoadID: 0,
loads: [:]
)
Expand Down Expand Up @@ -1540,6 +1555,7 @@ final class ProcessControlPlaneAuthority: Sendable {
readinessToken: nil,
retryTimeout: nil,
retryKind: nil,
catalogTimeoutCount: 0,
nextLoadID: 0,
loads: [:]
)
Expand Down Expand Up @@ -1592,6 +1608,7 @@ final class ProcessControlPlaneAuthority: Sendable {
readinessToken: nil,
retryTimeout: nil,
retryKind: nil,
catalogTimeoutCount: 0,
nextLoadID: 0,
loads: [:]
)
Expand Down Expand Up @@ -2221,6 +2238,7 @@ final class ProcessControlPlaneAuthority: Sendable {
attempt.retryTimeout = nil
attempt.retryKind = .catalog
attempt.phase = .backoff
attempt.catalogTimeoutCount &+= 1
record.catalogRetryCount &+= 1
record.attempt = attempt
state.recordsByKey[key] = record
Expand All @@ -2232,7 +2250,8 @@ final class ProcessControlPlaneAuthority: Sendable {
publishesToolsListChanged: false
),
retry: Self.retry(forAttempt: record.catalogRetryCount),
catalogLease: lease
catalogLease: lease,
timeoutCount: attempt.catalogTimeoutCount
)
}
}
Expand Down Expand Up @@ -2295,6 +2314,7 @@ final class ProcessControlPlaneAuthority: Sendable {
rejectedBridgeRecovery.upstreamID == failedProof.slotID,
let retry = Self.prepareBridgeRecoveryRetry(
rejectedBridgeRecovery,
failure: .other,
owner: &record
) {
state.recordsByKey[key] = record
Expand Down Expand Up @@ -2599,17 +2619,26 @@ final class ProcessControlPlaneAuthority: Sendable {

private static func prepareBridgeRecoveryRetry(
_ recovery: ProcessBridgePoolRecovery,
failure: ProcessBridgeRecoveryFailure,
owner: inout XcodeProcessOwner
) -> ProcessBridgeRecoveryRetry? {
guard case .attempting(let current) = owner.bridgeRecovery.phase,
current == recovery else { return nil }
owner.bridgeRecovery.consecutiveFailureCount += 1
let shouldLogToolsUnavailableWarning =
failure == .toolsListTimeout
&& owner.bridgeRecovery.didLogToolsUnavailableWarning == false
if shouldLogToolsUnavailableWarning {
owner.bridgeRecovery.didLogToolsUnavailableWarning = true
}
owner.bridgeRecovery.phase = .waitingRetry(recovery, nil)
return ProcessBridgeRecoveryRetry(
reservation: recovery,
delay: owner.bridgeRecovery.consecutiveFailureCount == 1
? .seconds(1)
: .seconds(10)
: .seconds(10),
consecutiveFailureCount: owner.bridgeRecovery.consecutiveFailureCount,
shouldLogToolsUnavailableWarning: shouldLogToolsUnavailableWarning
)
}

Expand Down Expand Up @@ -2665,6 +2694,7 @@ final class ProcessControlPlaneAuthority: Sendable {
readinessToken: nil,
retryTimeout: nil,
retryKind: .activation,
catalogTimeoutCount: 0,
nextLoadID: 0,
loads: [:]
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Logging
import NIO
import NIOFoundationCompat
import XcodeMCPKit
Expand Down Expand Up @@ -305,6 +306,7 @@ extension RuntimeCoordinator {
"upstream": .string("\(probe.upstreamIndex)"),
"success": .string(success ? "true" : "false"),
"reason": .string(reason),
"method": .string("tools/list"),
]
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ extension RuntimeCoordinator {
) {
var retry: ProcessBridgeRecoveryRetry?
guard initializeManager.performIfRunning({
retry = processControlPlane.prepareBridgeRecoveryRetry(recovery)
retry = processControlPlane.prepareBridgeRecoveryRetry(
recovery,
failure: reason == "attach_probe_timeout" ? .toolsListTimeout : .other
)
}), let retry else {
return
}
Expand All @@ -404,8 +407,17 @@ extension RuntimeCoordinator {
"upstream": .string("\(retry.reservation.upstreamID.rawValue)"),
"reason": .string(reason),
"delay_ms": .string("\(retry.delay.nanoseconds / 1_000_000)"),
"consecutive_failures": .string("\(retry.consecutiveFailureCount)"),
]
)
if retry.shouldLogToolsUnavailableWarning {
XcodeMCPToolsAvailabilityDiagnostic.logTimeout(
logger: logger,
processID: retry.reservation.routeID.processID,
upstreamIndex: retry.reservation.upstreamID.rawValue,
retryDelayMilliseconds: retry.delay.nanoseconds / 1_000_000
)
}
let timeout = scheduleRuntimeTimeout(retry.delay) { [weak self] in
guard let self else { return }
self.applyProcessControlPlaneTransition(
Expand Down Expand Up @@ -439,7 +451,8 @@ extension RuntimeCoordinator {
var retry: ProcessBridgeRecoveryRetry?
guard initializeManager.performIfRunning({
retry = processControlPlane.prepareBridgeRecoveryRetry(
recovery.reservation
recovery.reservation,
failure: reason == "attach_probe_timeout" ? .toolsListTimeout : .other
)
}), let retry else { return }
guard let replacement else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,8 @@ extension RuntimeCoordinator {
guard case .retryRequired(
_,
let retry,
let retryLease
let retryLease,
let catalogTimeoutCount
) = timeout else {
return
}
Expand All @@ -495,12 +496,22 @@ extension RuntimeCoordinator {
"upstream": .string("\(lease.upstreamIndex)"),
"attempt": .string("\(lease.attempt)"),
"phase": .string("catalog"),
"method": .string("tools/list"),
"timeout_ms": .string(
processRouteActivationCatalogTimeoutMillisecondsDescription()
),
"catalog_timeout_count": .string("\(catalogTimeoutCount)"),
"retry_delay_ms": .string("\(retry.delayMilliseconds)"),
]
)
if catalogTimeoutCount == 1 {
XcodeMCPToolsAvailabilityDiagnostic.logTimeout(
logger: logger,
processID: lease.processID,
upstreamIndex: lease.upstreamIndex,
retryDelayMilliseconds: retry.delayMilliseconds
)
}

scheduleMissingProcessToolsCatalogRetry(
processID: lease.processID,
Expand Down
31 changes: 31 additions & 0 deletions Sources/XcodeMCPProxyRuntime/Session/Support/ProxyLogging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,34 @@ enum LogLevelParser {
}
}
}

enum XcodeMCPToolsAvailabilityDiagnostic {
static let timeoutSummary = """
Xcode tools are unavailable

The proxy timed out waiting for tools/list.
Recovery:
1. Open a project in Xcode.
2. Check that "Allow external agents to use Xcode tools" is enabled in
Xcode > Settings > Intelligence.
3. If Xcode shows a connection dialog, approve it.
The proxy will retry automatically.
"""

static func logTimeout(
logger: Logger,
processID: pid_t,
upstreamIndex: Int,
retryDelayMilliseconds: Int64
) {
logger.warning(
"\(timeoutSummary)",
metadata: [
"pid": .string("\(processID)"),
"upstream": .string("\(upstreamIndex)"),
"method": .string("tools/list"),
"retry_delay_ms": .string("\(retryDelayMilliseconds)"),
]
)
}
}
Loading