Bake in Xcode's MCP server on macOS

Xcode 26.3+ exposes an MCP server to external agents over stdio (`xcrun mcpbridge`). Now that sigit has a generic stdio transport, add a baked-in `xcode` server on macOS so agents can drive a running Xcode with zero config, mirroring the baked-in official server. It is added only when Xcode is actually running (a cheap `pgrep` check), so there's no startup cost or failed handshake when Xcode is closed. A user-defined `xcode` entry in mcp.toml always wins, and `SIGIT_MCP_XCODE=off` opts out. The server still requires Xcode's "Allow external agents to use Xcode tools" setting to be enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6UuZrohog7z14bLiti7US

Claude committed Jul 8, 2026 at 12:28 UTC 3275097d0683290466020e27fb11c4ee06fbec44
2 files changed +83 -2
.agents/AGENTS.md
+5 -2
@@ -134,7 +134,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
134 layer and routed in `tools::execute_tool` via `mcp::is_mcp_tool`. The official server
135 (`<cloud>/mcp`, default `https://sigit.si/api/v1/mcp`) is baked in (always HTTP) and authed
136 with the cloud session token; extra servers live in `mcp.toml` (global
137 - `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). The stdio path is covered by
137 + `$SIGIT_CONFIG_DIR/mcp.toml` and project-local `.sigit/mcp.toml`). On macOS, Xcode's stdio
138 + server (`xcrun mcpbridge`) is also baked in — added only when Xcode is running, requiring
139 + Xcode's "Allow external agents" setting, opt-out via `SIGIT_MCP_XCODE=off`, overridable by a
140 + user `xcode` entry. The stdio path is covered by
141 `tests/mcp_stdio.rs`, driven by the test-only `src/bin/mcp_stdio_stub.rs` helper binary
142 (excluded from the published crate via `exclude` in `Cargo.toml`).
143 - **`src/permissions.rs`** — tool permission policy. Every tool call passes through
@@ -191,7 +194,7 @@ verbosity with `RUST_LOG`.
194 `OPENAI_BASE_URL` / `OPENAI_API_KEY` (provider override), `SIGIT_API_URL` (account API base,
195 default `https://sigit.si`), `SIGIT_CLOUD_URL`, `SIGIT_CONFIG_DIR` (default `~/.config/sigit`),
196 `SIGIT_MODEL`, `SIGIT_MCP` (`off` disables MCP), `SIGIT_MCP_OFFICIAL` (`off` drops the baked-in
194 -server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
197 +server), `SIGIT_MCP_XCODE` (`off` drops the baked-in macOS Xcode server), `SIGIT_PERMISSIONS` (`allow`/`ask`/`deny` — overrides the default permission mode for
198 mutating tools; the escape hatch for clients without permission-request support),
199 `HF_HOME` / `HF_HUB_CACHE`, `RUST_LOG`.
200
src/mcp.rs
+78
@@ -29,6 +29,17 @@
29 //! credential. Additional servers are configured in `mcp.toml` (see
30 //! [`load_configs`]).
31 //!
32 +//! ## Xcode (macOS)
33 +//!
34 +//! On macOS siGit Code also bakes in [Xcode's MCP server][xcode-mcp] over stdio
35 +//! (`xcrun mcpbridge`), so agents can build, test, and edit a project open in
36 +//! Xcode with no configuration. It is added only when Xcode is actually running,
37 +//! and still requires *Xcode ▸ Settings ▸ Intelligence ▸ "Allow external agents
38 +//! to use Xcode tools"* to be enabled. Opt out with `SIGIT_MCP_XCODE=off`; a
39 +//! user-defined `xcode` entry in `mcp.toml` overrides the baked-in default.
40 +//!
41 +//! [xcode-mcp]: https://developer.apple.com/documentation/xcode/giving-external-agents-access-to-xcode
42 +//!
43 //! ## Lifecycle
44 //!
45 //! Discovery is best-effort and happens once at startup via [`init`]: each
@@ -433,9 +444,53 @@ fn load_configs() -> Vec<ServerDef> {
444 });
445 }
446
447 + // On macOS, bake in Xcode's stdio MCP server (`xcrun mcpbridge`) so agents
448 + // can drive a running Xcode with zero config. It is added only when Xcode is
449 + // actually running — spawning `mcpbridge` with no Xcode to bridge into is
450 + // pointless and would only add a failed handshake to every startup. A
451 + // user-defined entry named `xcode` (e.g. a custom command) always wins, and
452 + // `SIGIT_MCP_XCODE=off` opts out entirely. Note the server still requires
453 + // Xcode ▸ Settings ▸ Intelligence ▸ "Allow external agents to use Xcode
454 + // tools" to be enabled; without it the handshake fails and it contributes
455 + // no tools.
456 + #[cfg(target_os = "macos")]
457 + if !xcode_opted_out() && is_xcode_running() && !defs.iter().any(|d| d.name == "xcode") {
458 + defs.push(ServerDef {
459 + name: "xcode".to_string(),
460 + transport: TransportDef::Stdio {
461 + command: "xcrun".to_string(),
462 + args: vec!["mcpbridge".to_string()],
463 + env: Vec::new(),
464 + },
465 + });
466 + }
467 +
468 defs
469 }
470
471 +/// Whether the baked-in Xcode server is disabled via `SIGIT_MCP_XCODE=off`.
472 +#[cfg(target_os = "macos")]
473 +fn xcode_opted_out() -> bool {
474 + std::env::var("SIGIT_MCP_XCODE").is_ok_and(|value| {
475 + matches!(
476 + value.trim().to_ascii_lowercase().as_str(),
477 + "off" | "0" | "false" | "no" | "disabled"
478 + )
479 + })
480 +}
481 +
482 +/// Best-effort check for a running Xcode, so the baked-in `xcode` server is only
483 +/// added when there's something for `xcrun mcpbridge` to bridge into. Any error
484 +/// (e.g. `pgrep` missing) is treated as "not running".
485 +#[cfg(target_os = "macos")]
486 +fn is_xcode_running() -> bool {
487 + std::process::Command::new("pgrep")
488 + .args(["-xq", "Xcode"])
489 + .status()
490 + .map(|status| status.success())
491 + .unwrap_or(false)
492 +}
493 +
494 /// Insert `def`, replacing any existing entry with the same name.
495 fn upsert(defs: &mut Vec<ServerDef>, def: ServerDef) {
496 if let Some(slot) = defs.iter_mut().find(|d| d.name == def.name) {
@@ -1339,6 +1394,29 @@ mod tests {
1394 }
1395 }
1396
1397 + #[test]
1398 + fn user_xcode_entry_resolves_to_its_stdio_command() {
1399 + // A user-defined `xcode` server in mcp.toml overrides the baked-in macOS
1400 + // default (which uses `xcrun mcpbridge`); it must resolve like any other
1401 + // stdio entry.
1402 + let toml = r#"
1403 + [[server]]
1404 + name = "xcode"
1405 + command = "xcrun"
1406 + args = ["mcpbridge"]
1407 + "#;
1408 + let parsed: McpFile = toml::from_str(toml).unwrap();
1409 + let entry = &parsed.server[0];
1410 + assert_eq!(sanitize(&entry.name), "xcode");
1411 + match entry.transport_def().expect("valid stdio entry") {
1412 + TransportDef::Stdio { command, args, .. } => {
1413 + assert_eq!(command, "xcrun");
1414 + assert_eq!(args, vec!["mcpbridge".to_string()]);
1415 + }
1416 + TransportDef::Http { .. } => panic!("expected a stdio transport"),
1417 + }
1418 + }
1419 +
1420 #[test]
1421 fn entry_with_url_and_command_is_a_config_error() {
1422 let toml = r#"