@smbcloud / cli / commits / 6bd0c67

Add support for all Apple platforms

Seto Elkahfi committed Jul 30, 2026 at 21:50 UTC 6bd0c67bcbf380a34a6031a4b17ffe719227beaf
7 files changed +351 -25
crates/cli/src/cli/mod.rs
+4 -2
@@ -141,8 +141,10 @@ pub enum ControlKitCommands {
141 },
142 #[clap(about = "Call a running ControlKit JSON-RPC method.")]
143 Call {
144 - #[arg(long)]
145 - device_udid: String,
144 + #[arg(long, required_unless_present = "host", conflicts_with = "host")]
145 + device_udid: Option<String>,
146 + #[arg(long, conflicts_with = "device_udid")]
147 + host: Option<String>,
148 #[arg(long, default_value_t = 12004)]
149 port: u16,
150 #[arg(long)]
crates/cli/src/controlkit.rs
+13 -3
@@ -83,15 +83,25 @@ pub async fn process_controlkit(command: ControlKitCommands) -> Result<CommandRe
83 }
84 ControlKitCommands::Call {
85 device_udid,
86 + host,
87 port,
88 method,
89 params,
90 } => {
90 - let tools = XcodeCommandLineTools::new();
91 - let tunnel_host = tools.device_tunnel_address(&device_udid)?;
91 + let host = match (host, device_udid) {
92 + (Some(host), None) => host,
93 + (None, Some(device_udid)) => {
94 + XcodeCommandLineTools::new().device_tunnel_address(&device_udid)?
95 + }
96 + _ => {
97 + return Err(anyhow!(
98 + "provide either --host or --device-udid for the ControlKit runner"
99 + ));
100 + }
101 + };
102 let params = serde_json::from_str(&params)
103 .with_context(|| format!("invalid JSON for --params: {params}"))?;
94 - let result = ControlKit::with_host(&tunnel_host, port)
104 + let result = ControlKit::with_host(&host, port)
105 .call(&method, params)
106 .await?;
107 Ok(done_result(&serde_json::to_string_pretty(&result)?))
crates/cli/src/mcp.rs
+2 -1
@@ -932,7 +932,8 @@ impl ServerHandler for SmbMcpServer {
932 "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
933 `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
934 the tenant/project context other tools default to, shared with the CLI's own \
935 - `smb tenant use` / `smb project use`.",
935 + `smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \
936 + visionOS, watchOS, and macOS runners.",
937 )
938 }
939 }
crates/cli/src/mcp_xcrs.rs
+5 -1
@@ -17,5 +17,9 @@ xcrs::xcrs_mcp_tools!(
17 "smb_simulator_press_home",
18 "smb_simulator_press_button",
19 "smb_simulator_orientation_get",
20 - "smb_simulator_orientation_set"
20 + "smb_simulator_orientation_set",
21 + "smb_controlkit_capabilities",
22 + "smb_macos_click",
23 + "smb_visionos_spatial_tap",
24 + "smb_watchos_tap"
25 );
crates/xcrs/src/mcp.rs
+243 -15
@@ -172,6 +172,42 @@ pub struct SimulatorButtonArgs {
172 pub button: String,
173 }
174
175 +#[derive(Debug, Deserialize, JsonSchema)]
176 +pub struct ControlKitEndpointArgs {
177 + /// Exact simulator name. Omit this when connecting to a local macOS runner.
178 + #[serde(default)]
179 + pub simulator_name: Option<String>,
180 + /// Simulator UDID. Omit this when connecting to a local macOS runner.
181 + #[serde(default)]
182 + pub simulator_udid: Option<String>,
183 + /// ControlKit host. Defaults to 127.0.0.1 for local runners.
184 + #[serde(default)]
185 + pub host: Option<String>,
186 + /// Local ControlKit JSON-RPC port. Defaults to 12004.
187 + #[serde(default)]
188 + pub controlkit_port: Option<u16>,
189 +}
190 +
191 +#[derive(Debug, Deserialize, JsonSchema)]
192 +pub struct ControlKitCoordinateArgs {
193 + /// Exact simulator name. Omit this when connecting to a local macOS runner.
194 + #[serde(default)]
195 + pub simulator_name: Option<String>,
196 + /// Simulator UDID. Omit this when connecting to a local macOS runner.
197 + #[serde(default)]
198 + pub simulator_udid: Option<String>,
199 + /// ControlKit host. Defaults to 127.0.0.1 for local runners.
200 + #[serde(default)]
201 + pub host: Option<String>,
202 + /// Local ControlKit JSON-RPC port. Defaults to 12004.
203 + #[serde(default)]
204 + pub controlkit_port: Option<u16>,
205 + /// Horizontal screen coordinate.
206 + pub x: f32,
207 + /// Vertical screen coordinate.
208 + pub y: f32,
209 +}
210 +
211 #[derive(Debug, Default)]
212 pub struct XcrsMcpServer;
213
@@ -200,7 +236,11 @@ macro_rules! xcrs_mcp_tools {
236 $simulator_home_name:literal,
237 $simulator_button_name:literal,
238 $simulator_orientation_get_name:literal,
203 - $simulator_orientation_set_name:literal
239 + $simulator_orientation_set_name:literal,
240 + $controlkit_capabilities_name:literal,
241 + $macos_click_name:literal,
242 + $visionos_spatial_tap_name:literal,
243 + $watchos_tap_name:literal
244 ) => {
245 #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
246 impl $server {
@@ -248,9 +288,193 @@ macro_rules! xcrs_mcp_tools {
288 ))
289 }
290
291 + fn controlkit_from_endpoint(
292 + endpoint: &$crate::mcp::ControlKitEndpointArgs,
293 + ) -> ::std::result::Result<
294 + ($crate::ControlKit, Option<$crate::Simulator>),
295 + ::rmcp::model::ErrorData,
296 + > {
297 + let simulator = match (&endpoint.simulator_udid, &endpoint.simulator_name) {
298 + (Some(udid), _) => Some(
299 + $crate::XcodeCommandLineTools::new()
300 + .simctl()
301 + .find_simulator_by_udid(udid)
302 + .map_err(|error| {
303 + ::rmcp::model::ErrorData::invalid_request(
304 + error.to_string(),
305 + None,
306 + )
307 + })?,
308 + ),
309 + (None, Some(name)) => Some(
310 + $crate::XcodeCommandLineTools::new()
311 + .simctl()
312 + .find_simulator_by_name(name)
313 + .map_err(|error| {
314 + ::rmcp::model::ErrorData::invalid_request(
315 + error.to_string(),
316 + None,
317 + )
318 + })?,
319 + ),
320 + (None, None) => None,
321 + };
322 + let host = endpoint.host.as_deref().unwrap_or("127.0.0.1");
323 + Ok((
324 + $crate::ControlKit::with_host(
325 + host,
326 + endpoint.controlkit_port.unwrap_or(12004),
327 + ),
328 + simulator,
329 + ))
330 + }
331 +
332 + fn endpoint_from_coordinate(
333 + args: &$crate::mcp::ControlKitCoordinateArgs,
334 + ) -> $crate::mcp::ControlKitEndpointArgs {
335 + $crate::mcp::ControlKitEndpointArgs {
336 + simulator_name: args.simulator_name.clone(),
337 + simulator_udid: args.simulator_udid.clone(),
338 + host: args.host.clone(),
339 + controlkit_port: args.controlkit_port,
340 + }
341 + }
342 +
343 + #[::rmcp::tool(
344 + name = $controlkit_capabilities_name,
345 + description = "Read the platform and supported capabilities from a ControlKit runner. Use a simulator name/UDID for iOS, tvOS, watchOS, or visionOS; omit them for a local macOS runner."
346 + )]
347 + async fn controlkit_capabilities(
348 + &self,
349 + ::rmcp::handler::server::wrapper::Parameters(
350 + args,
351 + ): ::rmcp::handler::server::wrapper::Parameters<
352 + $crate::mcp::ControlKitEndpointArgs,
353 + >,
354 + ) -> ::std::result::Result<
355 + ::rmcp::model::CallToolResult,
356 + ::rmcp::model::ErrorData,
357 + > {
358 + let (controlkit, simulator) = Self::controlkit_from_endpoint(&args)?;
359 + let result = controlkit
360 + .call("device.capabilities", ::serde_json::json!({}))
361 + .await
362 + .map_err(|error| {
363 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
364 + })?;
365 + Ok(::rmcp::model::CallToolResult::success(vec![
366 + ::rmcp::model::ContentBlock::json(&::serde_json::json!({
367 + "simulator": simulator,
368 + "capabilities": result,
369 + }))?,
370 + ]))
371 + }
372 +
373 + #[::rmcp::tool(
374 + name = $macos_click_name,
375 + description = "Click a macOS ControlKit runner at screen coordinates. Omit simulator_name and simulator_udid for the local Mac."
376 + )]
377 + async fn macos_click(
378 + &self,
379 + ::rmcp::handler::server::wrapper::Parameters(
380 + args,
381 + ): ::rmcp::handler::server::wrapper::Parameters<
382 + $crate::mcp::ControlKitCoordinateArgs,
383 + >,
384 + ) -> ::std::result::Result<
385 + ::rmcp::model::CallToolResult,
386 + ::rmcp::model::ErrorData,
387 + > {
388 + let endpoint = Self::endpoint_from_coordinate(&args);
389 + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?;
390 + controlkit
391 + .call(
392 + "device.io.click",
393 + ::serde_json::json!({ "x": args.x, "y": args.y }),
394 + )
395 + .await
396 + .map_err(|error| {
397 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
398 + })?;
399 + Ok(::rmcp::model::CallToolResult::success(vec![
400 + ::rmcp::model::ContentBlock::text(format!(
401 + "Clicked ({}, {}).",
402 + args.x, args.y
403 + )),
404 + ]))
405 + }
406 +
407 + #[::rmcp::tool(
408 + name = $visionos_spatial_tap_name,
409 + description = "Perform a spatial tap on a visionOS ControlKit runner."
410 + )]
411 + async fn visionos_spatial_tap(
412 + &self,
413 + ::rmcp::handler::server::wrapper::Parameters(
414 + args,
415 + ): ::rmcp::handler::server::wrapper::Parameters<
416 + $crate::mcp::ControlKitCoordinateArgs,
417 + >,
418 + ) -> ::std::result::Result<
419 + ::rmcp::model::CallToolResult,
420 + ::rmcp::model::ErrorData,
421 + > {
422 + let endpoint = Self::endpoint_from_coordinate(&args);
423 + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?;
424 + controlkit
425 + .call(
426 + "device.io.spatial.tap",
427 + ::serde_json::json!({ "x": args.x, "y": args.y }),
428 + )
429 + .await
430 + .map_err(|error| {
431 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
432 + })?;
433 + Ok(::rmcp::model::CallToolResult::success(vec![
434 + ::rmcp::model::ContentBlock::text(format!(
435 + "Performed spatial tap at ({}, {}).",
436 + args.x, args.y
437 + )),
438 + ]))
439 + }
440 +
441 + #[::rmcp::tool(
442 + name = $watchos_tap_name,
443 + description = "Tap a watchOS ControlKit runner at screen coordinates."
444 + )]
445 + async fn watchos_tap(
446 + &self,
447 + ::rmcp::handler::server::wrapper::Parameters(
448 + args,
449 + ): ::rmcp::handler::server::wrapper::Parameters<
450 + $crate::mcp::ControlKitCoordinateArgs,
451 + >,
452 + ) -> ::std::result::Result<
453 + ::rmcp::model::CallToolResult,
454 + ::rmcp::model::ErrorData,
455 + > {
456 + let endpoint = Self::endpoint_from_coordinate(&args);
457 + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?;
458 + controlkit
459 + .call(
460 + "device.io.tap",
461 + ::serde_json::json!({ "x": args.x, "y": args.y }),
462 + )
463 + .await
464 + .map_err(|error| {
465 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
466 + })?;
467 + Ok(::rmcp::model::CallToolResult::success(vec![
468 + ::rmcp::model::ContentBlock::text(format!(
469 + "Tapped ({}, {}).",
470 + args.x, args.y
471 + )),
472 + ]))
473 + }
474 +
475 #[::rmcp::tool(
476 name = $simulator_list_name,
253 - description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state."
477 + description = "List all Apple simulator devices known to Xcode, including their platform, runtime, UDID, availability, and current state."
478 )]
479 async fn simulator_list(
480 &self,
@@ -271,7 +495,7 @@ macro_rules! xcrs_mcp_tools {
495
496 #[::rmcp::tool(
497 name = $simulator_find_name,
274 - description = "Find an iOS simulator by its exact name and return its details as JSON."
498 + description = "Find an Apple simulator by its exact name and return its details as JSON."
499 )]
500 async fn simulator_find(
501 &self,
@@ -335,7 +559,7 @@ macro_rules! xcrs_mcp_tools {
559
560 #[::rmcp::tool(
561 name = $simulator_screenshot_name,
338 - description = "Capture a PNG screenshot from an iOS simulator."
562 + description = "Capture a PNG screenshot from an Apple simulator."
563 )]
564 async fn simulator_screenshot(
565 &self,
@@ -363,7 +587,7 @@ macro_rules! xcrs_mcp_tools {
587
588 #[::rmcp::tool(
589 name = $simulator_launch_app_name,
366 - description = "Launch an installed app on an iOS simulator by bundle identifier."
590 + description = "Launch an installed app on an Apple simulator by bundle identifier."
591 )]
592 async fn simulator_launch_app(
593 &self,
@@ -398,7 +622,7 @@ macro_rules! xcrs_mcp_tools {
622
623 #[::rmcp::tool(
624 name = $simulator_terminate_app_name,
401 - description = "Terminate an installed app on an iOS simulator by bundle identifier."
625 + description = "Terminate an installed app on an Apple simulator by bundle identifier."
626 )]
627 async fn simulator_terminate_app(
628 &self,
@@ -433,7 +657,7 @@ macro_rules! xcrs_mcp_tools {
657
658 #[::rmcp::tool(
659 name = $simulator_open_url_name,
436 - description = "Open a URL or custom URL scheme on an iOS simulator."
660 + description = "Open a URL or custom URL scheme on an Apple simulator."
661 )]
662 async fn simulator_open_url(
663 &self,
@@ -468,7 +692,7 @@ macro_rules! xcrs_mcp_tools {
692
693 #[::rmcp::tool(
694 name = $simulator_ui_dump_name,
471 - description = "Return the accessibility UI hierarchy from the foreground iOS app through ControlKit."
695 + description = "Return the accessibility UI hierarchy from the foreground Apple app through ControlKit."
696 )]
697 async fn simulator_ui_dump(
698 &self,
@@ -502,7 +726,7 @@ macro_rules! xcrs_mcp_tools {
726
727 #[::rmcp::tool(
728 name = $simulator_list_elements_name,
505 - description = "List actionable accessibility elements and coordinates from the foreground iOS app through ControlKit."
729 + description = "List actionable accessibility elements and coordinates from the foreground Apple app through ControlKit."
730 )]
731 async fn simulator_list_elements(
732 &self,
@@ -537,7 +761,7 @@ macro_rules! xcrs_mcp_tools {
761
762 #[::rmcp::tool(
763 name = $simulator_tap_name,
540 - description = "Tap the iOS simulator screen at the given coordinates through ControlKit."
764 + description = "Tap an Apple simulator screen at the given coordinates through ControlKit."
765 )]
766 async fn simulator_tap(
767 &self,
@@ -574,7 +798,7 @@ macro_rules! xcrs_mcp_tools {
798
799 #[::rmcp::tool(
800 name = $simulator_text_name,
577 - description = "Type text into the focused iOS simulator field through ControlKit."
801 + description = "Type text into the focused Apple simulator field through ControlKit."
802 )]
803 async fn simulator_text(
804 &self,
@@ -608,7 +832,7 @@ macro_rules! xcrs_mcp_tools {
832
833 #[::rmcp::tool(
834 name = $simulator_swipe_name,
611 - description = "Swipe between two screen coordinates on an iOS simulator through ControlKit."
835 + description = "Swipe between two screen coordinates on an Apple simulator through ControlKit."
836 )]
837 async fn simulator_swipe(
838 &self,
@@ -650,7 +874,7 @@ macro_rules! xcrs_mcp_tools {
874
875 #[::rmcp::tool(
876 name = $simulator_home_name,
653 - description = "Press the iOS simulator Home button through ControlKit."
877 + description = "Press the iOS Home button through ControlKit."
878 )]
879 async fn simulator_home(
880 &self,
@@ -833,7 +1057,11 @@ xcrs_mcp_tools!(
1057 "xcrs_simulator_press_home",
1058 "xcrs_simulator_press_button",
1059 "xcrs_simulator_orientation_get",
836 - "xcrs_simulator_orientation_set"
1060 + "xcrs_simulator_orientation_set",
1061 + "xcrs_controlkit_capabilities",
1062 + "xcrs_macos_click",
1063 + "xcrs_visionos_spatial_tap",
1064 + "xcrs_watchos_tap"
1065 );
1066
1067 #[rmcp::tool_handler(router = Self::xcrs_tool_router())]
@@ -846,7 +1074,7 @@ impl ServerHandler for XcrsMcpServer {
1074 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1075 .with_server_info(implementation)
1076 .with_instructions(
849 - "xcrs exposes Xcode command line tools for iOS simulators and app testing.",
1077 + "xcrs exposes Xcode command line tools and ControlKit runners for Apple platform testing.",
1078 )
1079 }
1080 }
crates/xcrs/src/xcrs.rs
+49 -1
@@ -217,6 +217,8 @@ pub enum ApplePlatform {
217 Ios,
218 #[serde(rename = "tvOS")]
219 Tvos,
220 + #[serde(rename = "macOS")]
221 + Macos,
222 #[serde(rename = "watchOS")]
223 Watchos,
224 #[serde(rename = "visionOS")]
@@ -231,9 +233,12 @@ impl ApplePlatform {
233 Self::Ios
234 } else if runtime_identifier.contains(".tvOS-") {
235 Self::Tvos
236 + } else if runtime_identifier.contains(".macOS-") {
237 + Self::Macos
238 } else if runtime_identifier.contains(".watchOS-") {
239 Self::Watchos
236 - } else if runtime_identifier.contains(".visionOS-") {
240 + } else if runtime_identifier.contains(".visionOS-") || runtime_identifier.contains(".xrOS-")
241 + {
242 Self::Visionos
243 } else {
244 Self::Unknown
@@ -766,6 +771,49 @@ mod tests {
771 assert!(devices[0].is_booted());
772 }
773
774 + #[test]
775 + fn parses_xros_simulator_runtime() {
776 + let output = r#"{
777 + "devices": {
778 + "com.apple.CoreSimulator.SimRuntime.xrOS-26-5": [
779 + {
780 + "udid": "00000000-0000-0000-0000-000000000001",
781 + "isAvailable": true,
782 + "state": "Shutdown",
783 + "name": "Apple Vision Pro"
784 + }
785 + ]
786 + }
787 + }"#;
788 +
789 + let devices = parse_simctl_devices(output).expect("devices should parse");
790 +
791 + assert_eq!(devices.len(), 1);
792 + assert_eq!(devices[0].platform, ApplePlatform::Visionos);
793 + assert_eq!(devices[0].name, "Apple Vision Pro");
794 + }
795 +
796 + #[test]
797 + fn parses_macos_runtime() {
798 + let output = r#"{
799 + "devices": {
800 + "com.apple.CoreSimulator.SimRuntime.macOS-26-0": [
801 + {
802 + "udid": "00000000-0000-0000-0000-000000000002",
803 + "isAvailable": true,
804 + "state": "Booted",
805 + "name": "Mac"
806 + }
807 + ]
808 + }
809 + }"#;
810 +
811 + let devices = parse_simctl_devices(output).expect("devices should parse");
812 +
813 + assert_eq!(devices.len(), 1);
814 + assert_eq!(devices[0].platform, ApplePlatform::Macos);
815 + }
816 +
817 #[test]
818 fn formats_ipv6_controlkit_url() {
819 let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006);
docs/mcp.md
+35 -2
@@ -4,8 +4,9 @@ The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI
4 assistant or agent — Claude Desktop, Claude Code, Cursor, or any other
5 MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and
6 Auth apps directly, without you leaving the chat to run `smb` commands by hand.
7 -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 30
8 -tools covering the account, project, tenant, Mail, and Auth surfaces.
7 +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 50
8 +tools covering the account, project, tenant, Mail, Auth, simulator, and
9 +ControlKit runner surfaces.
10
11 This page is the setup guide and the full tool reference. For how `--mcp`
12 compares to the CLI's other two interfaces (headless and `--tui`), see
@@ -212,6 +213,38 @@ users; it belongs to a project.
213 | `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. |
214 | `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** |
215
216 +### ControlKit runners
217 +
218 +The `smb` MCP server also exposes local Xcode simulator and ControlKit runner
219 +tools. Simulator arguments accept either `simulator_name` or `simulator_udid`;
220 +ControlKit arguments default to `127.0.0.1:12004`. For a local macOS runner,
221 +omit the simulator fields and use the optional `host` and `controlkit_port`
222 +fields.
223 +
224 +| Tool | Arguments | Returns |
225 +| --- | --- | --- |
226 +| `smb_simulator_list` | _(none)_ | All Apple simulator devices known to Xcode. |
227 +| `smb_simulator_find` | `name` | A simulator's details. |
228 +| `smb_controlkit_capabilities` | simulator target or optional `host`, `controlkit_port` | Runner platform and capabilities. |
229 +| `smb_macos_click` | `x`, `y`, plus optional endpoint fields | A pointer click on a macOS runner. |
230 +| `smb_visionos_spatial_tap` | `x`, `y`, plus optional endpoint fields | A spatial tap on a visionOS runner. |
231 +| `smb_watchos_tap` | `x`, `y`, plus optional endpoint fields | A touch tap on a watchOS runner. |
232 +| `smb_simulator_tap` | simulator target, `x`, `y` | A touch tap through ControlKit. |
233 +| `smb_simulator_type_text` | simulator target, `text` | Text input through ControlKit. |
234 +| `smb_simulator_swipe` | simulator target, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. |
235 +| `smb_simulator_press_button` | simulator target, `button` | A supported remote-button press. |
236 +| `smb_simulator_press_home` | simulator target | An iOS Home-button press. |
237 +| `smb_simulator_orientation_get`/`set` | simulator target; `orientation` for `set` | Current or updated iOS orientation. |
238 +| `smb_simulator_launch_app`/`terminate_app` | simulator target, `bundle_id` | App lifecycle confirmation. |
239 +| `smb_simulator_open_url` | simulator target, `url` | URL-open confirmation. |
240 +| `smb_simulator_ui_dump` | simulator target | Accessibility UI hierarchy. |
241 +| `smb_simulator_list_elements` | simulator target | Actionable accessibility elements. |
242 +| `smb_simulator_screenshot` | simulator target | A PNG image. |
243 +| `smb_ios_app_test` | simulator target, `app_path`, `bundle_id` | Installed/launched app details. |
244 +
245 +The standalone `xcrs --mcp` server exposes the same runner surface with the
246 +`xcrs_` prefix instead of `smb_`.
247 +
248 ## Safety: tools run without confirmation
249
250 The CLI's own `project delete`, `tenant delete`, and similar commands ask for