From 383dccab6417eb42225eae3e3ade4cf97af08ea8 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:23:00 +0200 Subject: [PATCH 1/2] Rename Apple MCP tools to match agent tool-selection conventions The Apple-platform MCP tools were named `smb_simulator_*` even though most drive the app through a ControlKit runner on a simulator *or* a physical device *or* macOS. That wrong scope signal, divergence from the verbs coding agents already know (XcodeBuildMCP's `describe_ui`/`tap`/`screenshot`), and a per-call `simulator_name`/`simulator_udid`/`host`/`controlkit_port` matrix with no selection step made Codex, Copilot, and Claude Code pick the wrong tools. - Rename host-agnostic actions to bare ecosystem verbs: `describe_ui`, `list_elements`, `tap`, `type_text`, `swipe`, `button`, `orientation_get/set`, `launch_app`, `terminate_app`, `capabilities`, `click` (macOS), `gesture` (visionOS). Keep truly simulator-only tools honest: `list_simulators`, `find_simulator`, `boot_and_install`, `open_url`. - Add `use_target` so a simulator or device is selected once and later actions need no target arguments; missing target fields fall back to it. - Consolidate near-duplicates: merge `device_screenshot` into a single auto-routing `screenshot`, fold `press_home` into `button`, and `watchos_tap` into `tap` (~25 tools down to 19). - Rewrite descriptions to be intent-first with honest host scope and use-when hints, dropping "ControlKit"/"simulator" jargon from the user-facing surface. - Update docs/mcp.md and docs/controlkit.md with the new names and a select-a-target-then-act workflow. Both the `smb_*` and standalone `xcrs_*` surfaces are renamed in lockstep. Release Notes: - Renamed the Apple-platform MCP tools to clearer, host-agnostic names (e.g. `smb_tap`, `smb_describe_ui`, `smb_screenshot`) and added `smb_use_target` to select a simulator or device once for subsequent actions. This is a breaking change for any scripts using the previous `smb_simulator_*` tool names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/cli/src/mcp_xcrs.rs | 40 ++-- crates/xcrs/src/mcp.rs | 411 ++++++++++++++++++++++--------------- docs/controlkit.md | 29 +-- docs/mcp.md | 81 ++++---- 4 files changed, 317 insertions(+), 244 deletions(-) diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index 2df20e9..4b2e894 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -2,25 +2,23 @@ use super::SmbMcpServer; xcrs::xcrs_mcp_tools!( SmbMcpServer, - "smb_simulator_list", - "smb_simulator_find", - "smb_ios_app_test", - "smb_simulator_screenshot", - "smb_simulator_launch_app", - "smb_simulator_terminate_app", - "smb_simulator_open_url", - "smb_simulator_ui_dump", - "smb_simulator_list_elements", - "smb_simulator_tap", - "smb_simulator_type_text", - "smb_simulator_swipe", - "smb_simulator_press_home", - "smb_simulator_press_button", - "smb_simulator_orientation_get", - "smb_simulator_orientation_set", - "smb_controlkit_capabilities", - "smb_macos_click", - "smb_visionos_spatial_tap", - "smb_watchos_tap", - "smb_device_screenshot" + "smb_list_simulators", + "smb_find_simulator", + "smb_boot_and_install", + "smb_screenshot", + "smb_launch_app", + "smb_terminate_app", + "smb_open_url", + "smb_describe_ui", + "smb_list_elements", + "smb_tap", + "smb_type_text", + "smb_swipe", + "smb_button", + "smb_orientation_get", + "smb_orientation_set", + "smb_capabilities", + "smb_click", + "smb_gesture", + "smb_use_target" ); diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 0869087..8f0f67b 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -17,10 +17,49 @@ pub struct SimulatorFindArgs { } #[derive(Debug, Deserialize, JsonSchema)] -pub struct DeviceScreenshotArgs { - /// Physical device identifier known to CoreDevice: UDID, ECID, serial - /// number, user-provided name, or DNS name. - pub device: String, +pub struct ScreenshotArgs { + /// Simulator name of the target to capture. Omit to use the target selected + /// with the use-target tool. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID of the target to capture. + #[serde(default)] + pub simulator_udid: Option, + /// Physical device identifier known to CoreDevice (UDID, ECID, serial + /// number, user-provided name, or DNS name). Provide this to capture a + /// paired physical device instead of a simulator. + #[serde(default)] + pub device: Option, +} + +/// A target chosen once with the use-target tool and reused by later actions so +/// they no longer need per-call target arguments. +#[derive(Debug, Clone, Default, Deserialize, JsonSchema, serde::Serialize)] +pub struct StoredTarget { + #[serde(default)] + pub simulator_name: Option, + #[serde(default)] + pub simulator_udid: Option, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub controlkit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UseTargetArgs { + /// Exact simulator name to remember as the active target. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID to remember as the active target. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host of a physical device or remote runner to remember. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port to remember. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -261,23 +300,67 @@ macro_rules! xcrs_mcp_tools { $simulator_tap_name:literal, $simulator_text_name:literal, $simulator_swipe_name:literal, - $simulator_home_name:literal, $simulator_button_name:literal, $simulator_orientation_get_name:literal, $simulator_orientation_set_name:literal, $controlkit_capabilities_name:literal, $macos_click_name:literal, $visionos_spatial_tap_name:literal, - $watchos_tap_name:literal, - $device_screenshot_name:literal + $use_target_name:literal ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { + fn selected_target_slot( + ) -> &'static ::std::sync::Mutex> { + static SLOT: ::std::sync::Mutex> = + ::std::sync::Mutex::new(None); + &SLOT + } + + fn stored_target() -> Option<$crate::mcp::StoredTarget> { + Self::selected_target_slot() + .lock() + .ok() + .and_then(|slot| slot.clone()) + } + + /// Fill missing target arguments from the target selected with the + /// use-target tool so per-call target fields become optional. + fn resolve_target_fields( + simulator_name: &Option, + simulator_udid: &Option, + host: &Option, + controlkit_port: Option, + ) -> (Option, Option, Option, Option) { + if simulator_name.is_none() && simulator_udid.is_none() && host.is_none() { + if let Some(stored) = Self::stored_target() { + return ( + stored.simulator_name, + stored.simulator_udid, + stored.host, + controlkit_port.or(stored.controlkit_port), + ); + } + } + ( + simulator_name.clone(), + simulator_udid.clone(), + host.clone(), + controlkit_port, + ) + } + fn simulator_from_target( target: &$crate::mcp::SimulatorTargetArgs, ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> { let tools = $crate::XcodeCommandLineTools::new(); - match (&target.simulator_udid, &target.simulator_name) { + let (simulator_name, simulator_udid, _host, _port) = Self::resolve_target_fields( + &target.simulator_name, + &target.simulator_udid, + &None, + target.controlkit_port, + ); + match (&simulator_udid, &simulator_name) { (Some(udid), _) => tools .simctl() .find_simulator_by_udid(udid) @@ -291,7 +374,7 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) }), (None, None) => Err(::rmcp::model::ErrorData::invalid_request( - "Provide either simulator_name or simulator_udid.", + "No target. Pass simulator_name or simulator_udid, or select one first with the use-target tool.", None, )), } @@ -306,15 +389,22 @@ macro_rules! xcrs_mcp_tools { (Option<$crate::Simulator>, $crate::ControlKit), ::rmcp::model::ErrorData, > { + let (simulator_name, simulator_udid, host, controlkit_port) = + Self::resolve_target_fields( + simulator_name, + simulator_udid, + host, + controlkit_port, + ); if let Some(host) = host { return Ok(( None, - $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)), + $crate::ControlKit::with_host(&host, controlkit_port.unwrap_or(12004)), )); } let target = $crate::mcp::SimulatorTargetArgs { - simulator_name: simulator_name.clone(), - simulator_udid: simulator_udid.clone(), + simulator_name, + simulator_udid, controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; @@ -337,7 +427,14 @@ macro_rules! xcrs_mcp_tools { ($crate::ControlKit, Option<$crate::Simulator>), ::rmcp::model::ErrorData, > { - let simulator = match (&endpoint.simulator_udid, &endpoint.simulator_name) { + let (simulator_name, simulator_udid, host, controlkit_port) = + Self::resolve_target_fields( + &endpoint.simulator_name, + &endpoint.simulator_udid, + &endpoint.host, + endpoint.controlkit_port, + ); + let simulator = match (&simulator_udid, &simulator_name) { (Some(udid), _) => Some( $crate::XcodeCommandLineTools::new() .simctl() @@ -362,11 +459,11 @@ macro_rules! xcrs_mcp_tools { ), (None, None) => None, }; - let host = endpoint.host.as_deref().unwrap_or("127.0.0.1"); + let host = host.unwrap_or_else(|| "127.0.0.1".to_string()); Ok(( $crate::ControlKit::with_host( - host, - endpoint.controlkit_port.unwrap_or(12004), + &host, + controlkit_port.unwrap_or(12004), ), simulator, )) @@ -383,9 +480,55 @@ macro_rules! xcrs_mcp_tools { } } + #[::rmcp::tool( + name = $use_target_name, + description = "Select the Apple target (simulator or physical/remote device) that later UI actions use by default, so you don't repeat target arguments on every call. Pass a simulator_name/simulator_udid for a simulator, or a host (plus optional controlkit_port) for a physical device or remote runner. Call this once after picking a target with the list-simulators tool; individual actions can still override it with their own target arguments." + )] + async fn use_target( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::UseTargetArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + if args.simulator_name.is_none() + && args.simulator_udid.is_none() + && args.host.is_none() + { + return Err(::rmcp::model::ErrorData::invalid_request( + "Provide a simulator_name/simulator_udid or a host to select as the active target.", + None, + )); + } + let stored = $crate::mcp::StoredTarget { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + host: args.host, + controlkit_port: args.controlkit_port, + }; + match Self::selected_target_slot().lock() { + Ok(mut slot) => *slot = Some(stored.clone()), + Err(_) => { + return Err(::rmcp::model::ErrorData::internal_error( + "Failed to store the selected target.", + None, + )) + } + } + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "selected": stored, + }))?, + ])) + } + #[::rmcp::tool( name = $controlkit_capabilities_name, - 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." + description = "Report the platform and supported input capabilities of the active Apple target. Use this to discover whether the target supports taps, spatial taps, buttons, or orientation before driving it. Works on a simulator, a physical/remote device, or the local Mac." )] async fn controlkit_capabilities( &self, @@ -415,7 +558,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $macos_click_name, - description = "Click a macOS ControlKit runner at screen coordinates. Omit simulator_name and simulator_udid for the local Mac." + description = "Click the running macOS app at screen coordinates. Use this for macOS targets; use the tap tool for iOS/tvOS/watchOS and the gesture tool for visionOS." )] async fn macos_click( &self, @@ -449,7 +592,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $visionos_spatial_tap_name, - description = "Perform a spatial tap on a visionOS ControlKit runner." + description = "Perform a spatial tap on the running visionOS app at screen coordinates. Use this for visionOS targets; use the tap tool for iOS/tvOS/watchOS and the click tool for macOS." )] async fn visionos_spatial_tap( &self, @@ -481,43 +624,9 @@ macro_rules! xcrs_mcp_tools { ])) } - #[::rmcp::tool( - name = $watchos_tap_name, - description = "Tap a watchOS ControlKit runner at screen coordinates." - )] - async fn watchos_tap( - &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitCoordinateArgs, - >, - ) -> ::std::result::Result< - ::rmcp::model::CallToolResult, - ::rmcp::model::ErrorData, - > { - let endpoint = Self::endpoint_from_coordinate(&args); - let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; - controlkit - .call( - "device.io.tap", - ::serde_json::json!({ "x": args.x, "y": args.y }), - ) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Tapped ({}, {}).", - args.x, args.y - )), - ])) - } - #[::rmcp::tool( name = $simulator_list_name, - description = "List all Apple simulator devices known to Xcode, including their platform, runtime, UDID, availability, and current state." + description = "List every Apple simulator known to Xcode with its platform, runtime, UDID, availability, and current state. Start here to pick a simulator, then remember it with the use-target tool." )] async fn simulator_list( &self, @@ -538,7 +647,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_find_name, - description = "Find an Apple simulator by its exact name and return its details as JSON." + description = "Find an Apple simulator by its exact name and return its details as JSON. Use this to resolve a UDID before selecting it with the use-target tool." )] async fn simulator_find( &self, @@ -564,7 +673,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $ios_app_test_name, - description = "Boot an iOS simulator, install an .app bundle, optionally terminate the bundle, launch it, and return its app container path." + description = "Boot an iOS simulator, install an .app bundle, optionally terminate it first, launch it, and return its app container path. Simulator-only end-to-end setup step; use launch-app/terminate-app for an already-installed app." )] async fn ios_app_test( &self, @@ -602,53 +711,40 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_screenshot_name, - description = "Capture a PNG screenshot from an Apple simulator." + description = "Capture a PNG screenshot of the running app. Targets a simulator (via simulator_name/simulator_udid or the selected target) or a paired physical device (via the device identifier). Use describe-ui instead when you need element coordinates rather than pixels." )] async fn simulator_screenshot( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTargetArgs, + $crate::mcp::ScreenshotArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let simulator = Self::simulator_from_target(&args)?; - let screenshot = $crate::XcodeCommandLineTools::new() - .simctl() - .screenshot(&simulator.udid) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - let encoded = $crate::encode_base64(screenshot); - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::image(encoded, "image/png"), - ])) - } - - #[::rmcp::tool( - name = $device_screenshot_name, - description = "Capture a PNG screenshot from a physical iOS, tvOS, watchOS, or visionOS device paired with this Mac. Accepts a UDID, ECID, serial number, user-provided name, or DNS name." - )] - async fn device_screenshot( - &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::DeviceScreenshotArgs, - >, - ) -> ::std::result::Result< - ::rmcp::model::CallToolResult, - ::rmcp::model::ErrorData, - > { - let screenshot = $crate::XcodeCommandLineTools::new() - .devicectl() - .screenshot(&args.device) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + let screenshot = if let Some(device) = &args.device { + $crate::XcodeCommandLineTools::new() + .devicectl() + .screenshot(device) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })? + } else { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name.clone(), + simulator_udid: args.simulator_udid.clone(), + controlkit_port: None, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .screenshot(&simulator.udid) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })? + }; let encoded = $crate::encode_base64(screenshot); Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::image(encoded, "image/png"), @@ -657,7 +753,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_launch_app_name, - description = "Launch an installed app on an Apple simulator by bundle identifier." + description = "Launch an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device." )] async fn simulator_launch_app( &self, @@ -670,9 +766,16 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - if let Some(host) = &args.host { + let (simulator_name, simulator_udid, host, controlkit_port) = + Self::resolve_target_fields( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + ); + if let Some(host) = &host { let controlkit = - $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); controlkit .call( "device.apps.launch", @@ -690,9 +793,9 @@ macro_rules! xcrs_mcp_tools { ])); } let target = $crate::mcp::SimulatorTargetArgs { - simulator_name: args.simulator_name, - simulator_udid: args.simulator_udid, - controlkit_port: args.controlkit_port, + simulator_name, + simulator_udid, + controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -711,7 +814,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_terminate_app_name, - description = "Terminate an installed app on an Apple simulator by bundle identifier." + description = "Terminate an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device." )] async fn simulator_terminate_app( &self, @@ -724,9 +827,16 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - if let Some(host) = &args.host { + let (simulator_name, simulator_udid, host, controlkit_port) = + Self::resolve_target_fields( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + ); + if let Some(host) = &host { let controlkit = - $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); controlkit .call( "device.apps.terminate", @@ -744,9 +854,9 @@ macro_rules! xcrs_mcp_tools { ])); } let target = $crate::mcp::SimulatorTargetArgs { - simulator_name: args.simulator_name, - simulator_udid: args.simulator_udid, - controlkit_port: args.controlkit_port, + simulator_name, + simulator_udid, + controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -765,7 +875,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_open_url_name, - description = "Open a URL or custom URL scheme on an Apple simulator." + description = "Open a URL or custom URL scheme on the active simulator target (simulator only)." )] async fn simulator_open_url( &self, @@ -800,7 +910,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_ui_dump_name, - description = "Return the accessibility UI hierarchy from the foreground Apple app through ControlKit." + description = "Return the full accessibility hierarchy of the foreground app as JSON. Use this first to see what is on screen before tapping or typing. Works on a simulator or a physical/remote device. Prefer list-elements when you only need actionable elements and their tap coordinates." )] async fn simulator_ui_dump( &self, @@ -835,7 +945,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_list_elements_name, - description = "List actionable accessibility elements and coordinates from the foreground Apple app through ControlKit." + description = "List just the actionable accessibility elements of the foreground app with their labels and tap coordinates. Use this to decide where to tap. Works on a simulator or a physical/remote device. Use describe-ui when you need the full hierarchy." )] async fn simulator_list_elements( &self, @@ -871,7 +981,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_tap_name, - description = "Tap an Apple simulator screen at the given coordinates through ControlKit." + description = "Tap the running app at screen coordinates. Use this for iOS, tvOS, and watchOS targets, on a simulator or a physical/remote device. Use the click tool for macOS and the gesture tool for visionOS. Read coordinates from list-elements or describe-ui first." )] async fn simulator_tap( &self, @@ -909,7 +1019,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_text_name, - description = "Type text into the focused Apple simulator field through ControlKit." + description = "Type text into the focused field of the running app. Works on a simulator or a physical/remote device. Tap the field first to focus it." )] async fn simulator_text( &self, @@ -944,7 +1054,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_swipe_name, - description = "Swipe between two screen coordinates on an Apple simulator through ControlKit." + description = "Swipe between two screen coordinates in the running app, e.g. to scroll. Works on a simulator or a physical/remote device." )] async fn simulator_swipe( &self, @@ -985,44 +1095,9 @@ macro_rules! xcrs_mcp_tools { ])) } - #[::rmcp::tool( - name = $simulator_home_name, - description = "Press the iOS Home button through ControlKit." - )] - async fn simulator_home( - &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorControlKitArgs, - >, - ) -> ::std::result::Result< - ::rmcp::model::CallToolResult, - ::rmcp::model::ErrorData, - > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, - args.controlkit_port, - )?; - controlkit - .call("device.io.button", ::serde_json::json!({ "button": "home" })) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Pressed Home on {}.", - Self::target_label(&simulator, &args.host) - )), - ])) - } - #[::rmcp::tool( name = $simulator_button_name, - description = "Press a ControlKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause." + description = "Press a hardware or remote button on the running target: home (iOS), or up, down, left, right, select, menu, playPause (tvOS remote). Works on a simulator or a physical/remote device. Use this to press Home instead of a dedicated tool." )] async fn simulator_button( &self, @@ -1076,7 +1151,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_orientation_get_name, - description = "Read the current iOS simulator orientation through ControlKit." + description = "Read the current screen orientation of the running target. Works on a simulator or a physical/remote device." )] async fn simulator_orientation_get( &self, @@ -1111,7 +1186,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_orientation_set_name, - description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through ControlKit." + description = "Set the screen orientation of the running target to PORTRAIT or LANDSCAPE. Works on a simulator or a physical/remote device." )] async fn simulator_orientation_set( &self, @@ -1159,27 +1234,25 @@ macro_rules! xcrs_mcp_tools { xcrs_mcp_tools!( XcrsMcpServer, - "xcrs_simulator_list", - "xcrs_simulator_find", - "xcrs_ios_app_test", - "xcrs_simulator_screenshot", - "xcrs_simulator_launch_app", - "xcrs_simulator_terminate_app", - "xcrs_simulator_open_url", - "xcrs_simulator_ui_dump", - "xcrs_simulator_list_elements", - "xcrs_simulator_tap", - "xcrs_simulator_type_text", - "xcrs_simulator_swipe", - "xcrs_simulator_press_home", - "xcrs_simulator_press_button", - "xcrs_simulator_orientation_get", - "xcrs_simulator_orientation_set", - "xcrs_controlkit_capabilities", - "xcrs_macos_click", - "xcrs_visionos_spatial_tap", - "xcrs_watchos_tap", - "xcrs_device_screenshot" + "xcrs_list_simulators", + "xcrs_find_simulator", + "xcrs_boot_and_install", + "xcrs_screenshot", + "xcrs_launch_app", + "xcrs_terminate_app", + "xcrs_open_url", + "xcrs_describe_ui", + "xcrs_list_elements", + "xcrs_tap", + "xcrs_type_text", + "xcrs_swipe", + "xcrs_button", + "xcrs_orientation_get", + "xcrs_orientation_set", + "xcrs_capabilities", + "xcrs_click", + "xcrs_gesture", + "xcrs_use_target" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] diff --git a/docs/controlkit.md b/docs/controlkit.md index 08d1fd7..22366bc 100644 --- a/docs/controlkit.md +++ b/docs/controlkit.md @@ -99,23 +99,24 @@ smb --mcp The runner tools accept either `simulator_name` or `simulator_udid` for a simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a -physical device or any other remote runner. The standalone `xcrs --mcp` -server exposes the same tools with the `xcrs_` prefix. +physical device or any other remote runner. Call `smb_use_target` once to +remember the target so later tools need no target arguments. The standalone +`xcrs --mcp` server exposes the same tools with the `xcrs_` prefix. | Tool | Runner | Purpose | | --- | --- | --- | -| `smb_controlkit_capabilities` | All platforms | Read platform and capability information. | -| `smb_macos_click` | macOS | Click at screen coordinates. | -| `smb_visionos_spatial_tap` | visionOS | Perform a spatial tap. | -| `smb_watchos_tap` | watchOS | Perform a touch tap. | -| `smb_simulator_tap` | iOS / tvOS, simulator or physical | Perform a touch tap. | -| `smb_simulator_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. | -| `smb_simulator_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | -| `smb_simulator_press_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | -| `smb_simulator_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | -| `smb_simulator_screenshot` | Simulator only | Capture a PNG screenshot via `simctl`. | -| `smb_device_screenshot` | Physical device only | Capture a PNG screenshot via `devicectl device capture screenshot` — works independently of the ControlKit runner, so it succeeds even if the RPC server isn't running. | -| `smb_simulator_ui_dump` | All UI-test runners | Read the accessibility hierarchy. | +| `smb_use_target` | All platforms | Remember the active simulator or device for later tools. | +| `smb_capabilities` | All platforms | Read platform and capability information. | +| `smb_click` | macOS | Click at screen coordinates. | +| `smb_gesture` | visionOS | Perform a spatial tap. | +| `smb_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. | +| `smb_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. | +| `smb_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | +| `smb_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | +| `smb_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | +| `smb_screenshot` | Simulator or physical | Capture a PNG screenshot. Uses `simctl` for a simulator, or `devicectl device capture screenshot` when a `device` identifier is given — the device path works even if the ControlKit runner isn't running. | +| `smb_describe_ui` | All UI-test runners | Read the accessibility hierarchy. | +| `smb_list_elements` | All UI-test runners | Read only actionable elements with tap coordinates. | For a local macOS runner, omit simulator fields and pass `host` and `controlkit_port` when needed: diff --git a/docs/mcp.md b/docs/mcp.md index c653da7..7a9b192 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -251,49 +251,50 @@ users; it belongs to a project. | `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. | | `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** | -### ControlKit runners - -The `smb` MCP server also exposes local Xcode simulator and ControlKit runner -tools. Simulator arguments accept either `simulator_name` or `simulator_udid`; -ControlKit arguments default to `127.0.0.1:12004`. For a local macOS runner, -omit the simulator fields and use the optional `host` and `controlkit_port` -fields. - -The same `host` field also targets a **physical** iOS/tvOS/watchOS/visionOS -device running a ControlKit runner reachable over the network (see -[ControlKit runners](./controlkit.md)) — omit `simulator_name`/`simulator_udid` -and pass the device's host and the runner's listen port instead. This applies -to `smb_simulator_tap`, `smb_simulator_type_text`, `smb_simulator_swipe`, -`smb_simulator_press_button`, `smb_simulator_press_home`, -`smb_simulator_orientation_get`/`set`, `smb_simulator_ui_dump`, -`smb_simulator_list_elements`, and `smb_simulator_launch_app`/`terminate_app` -(the latter two route through the runner's `device.apps.launch`/`terminate` -RPC methods instead of `simctl` when `host` is given). +### Apple platform automation + +The `smb` MCP server also exposes tools for driving Apple apps under test. Most +of the action tools are **host-agnostic**: the same tool works against an Xcode +simulator *or* a physical iOS/tvOS/watchOS/visionOS device *or* the local Mac, +because they talk to an on-target [ControlKit runner](./controlkit.md). + +**Recommended flow — select a target, then act:** + +1. `smb_list_simulators` (or `smb_find_simulator`) to pick a simulator, or note + a device's `host`. +2. `smb_use_target` once with the chosen `simulator_name`/`simulator_udid` (a + simulator) or `host` + optional `controlkit_port` (a device/remote runner). + The server remembers it, so later action tools need **no** target arguments. +3. `smb_describe_ui` / `smb_list_elements` to see the screen, then + `smb_tap` / `smb_type_text` / `smb_swipe` / `smb_button` and + `smb_screenshot` to drive and verify it. + +Any action tool can still override the selected target inline with its own +`simulator_name`/`simulator_udid` or `host` fields. Simulator arguments accept +either a name or a UDID; ControlKit arguments default to `127.0.0.1:12004`. | Tool | Arguments | Returns | | --- | --- | --- | -| `smb_simulator_list` | _(none)_ | All Apple simulator devices known to Xcode. | -| `smb_simulator_find` | `name` | A simulator's details. | -| `smb_controlkit_capabilities` | simulator target or optional `host`, `controlkit_port` | Runner platform and capabilities. | -| `smb_macos_click` | `x`, `y`, plus optional endpoint fields | A pointer click on a macOS runner. | -| `smb_visionos_spatial_tap` | `x`, `y`, plus optional endpoint fields | A spatial tap on a visionOS runner. | -| `smb_watchos_tap` | `x`, `y`, plus optional endpoint fields | A touch tap on a watchOS runner. | -| `smb_simulator_tap` | simulator target or `host`, `x`, `y` | A touch tap through ControlKit. | -| `smb_simulator_type_text` | simulator target or `host`, `text` | Text input through ControlKit. | -| `smb_simulator_swipe` | simulator target or `host`, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. | -| `smb_simulator_press_button` | simulator target or `host`, `button` | A supported remote-button press. | -| `smb_simulator_press_home` | simulator target or `host` | An iOS Home-button press. | -| `smb_simulator_orientation_get`/`set` | simulator target or `host`; `orientation` for `set` | Current or updated iOS orientation. | -| `smb_simulator_launch_app`/`terminate_app` | simulator target or `host`, `bundle_id` | App lifecycle confirmation. | -| `smb_simulator_open_url` | simulator target, `url` | URL-open confirmation. | -| `smb_simulator_ui_dump` | simulator target or `host` | Accessibility UI hierarchy. | -| `smb_simulator_list_elements` | simulator target or `host` | Actionable accessibility elements. | -| `smb_simulator_screenshot` | simulator target | A PNG image. | -| `smb_device_screenshot` | `device` (UDID, ECID, serial number, name, or DNS name) | A PNG image captured from a physical device via `devicectl`. | -| `smb_ios_app_test` | simulator target, `app_path`, `bundle_id` | Installed/launched app details. | - -The standalone `xcrs --mcp` server exposes the same runner surface with the -`xcrs_` prefix instead of `smb_`. +| `smb_use_target` | `simulator_name`/`simulator_udid`, or `host` + optional `controlkit_port` | The remembered active target. | +| `smb_list_simulators` | _(none)_ | All Apple simulator devices known to Xcode. | +| `smb_find_simulator` | `name` | A simulator's details (resolve a UDID). | +| `smb_capabilities` | optional target | Target platform and supported input capabilities. | +| `smb_describe_ui` | optional target | Full accessibility hierarchy of the foreground app. | +| `smb_list_elements` | optional target | Actionable accessibility elements with tap coordinates. | +| `smb_tap` | optional target, `x`, `y` | A touch tap (iOS/tvOS/watchOS). | +| `smb_click` | optional endpoint, `x`, `y` | A pointer click (macOS). | +| `smb_gesture` | optional endpoint, `x`, `y` | A spatial tap (visionOS). | +| `smb_type_text` | optional target, `text` | Text typed into the focused field. | +| `smb_swipe` | optional target, `x1`, `y1`, `x2`, `y2` | A swipe. | +| `smb_button` | optional target, `button` | A hardware/remote button press (e.g. `home`). | +| `smb_orientation_get`/`set` | optional target; `orientation` for `set` | Current or updated screen orientation. | +| `smb_launch_app`/`terminate_app` | optional target, `bundle_id` | App lifecycle confirmation (routes through the runner's `device.apps.launch`/`terminate` RPC on a device, `simctl` on a simulator). | +| `smb_open_url` | simulator target, `url` | URL-open confirmation (simulator only). | +| `smb_screenshot` | simulator target, or `device` (UDID/ECID/serial/name/DNS) | A PNG image (`simctl` for a simulator, `devicectl` for a physical device). | +| `smb_boot_and_install` | simulator target, `app_path`, `bundle_id` | Boots a simulator, installs and launches an app (simulator only). | + +The standalone `xcrs --mcp` server exposes the same surface with the `xcrs_` +prefix instead of `smb_`. ## Safety: tools run without confirmation From 8d87975ea1101bf1237b4ac54b97a89adf72f680 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:47:41 +0200 Subject: [PATCH 2/2] Add cross-platform mobile and TV MCP automation Unify Apple and Android device discovery, target selection, capabilities, and app control behind a focused 18-tool automation profile. Separate smbCloud tools from XCRS automation and update TDQS-oriented definitions, tests, registry metadata, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.MD | 11 + crates/cli/src/cli/mod.rs | 57 +- crates/cli/src/main.rs | 2 +- crates/cli/src/mcp.rs | 78 +- crates/cli/src/mcp_xcrs.rs | 174 ++- crates/xcrs/Cargo.toml | 4 +- crates/xcrs/README.md | 18 +- crates/xcrs/src/main.rs | 2 +- crates/xcrs/src/mcp.rs | 2530 ++++++++++++++++++++++++++---------- crates/xcrs/src/xcrs.rs | 525 ++++++++ docs/controlkit.md | 38 +- docs/mcp-registry.md | 4 +- docs/mcp.md | 124 +- server-xcrs.json | 24 +- 14 files changed, 2783 insertions(+), 808 deletions(-) diff --git a/README.MD b/README.MD index 36e0d35..76b2592 100644 --- a/README.MD +++ b/README.MD @@ -119,6 +119,17 @@ Auth comes from `smb login`, so a terminal session and an agent session share th token and the same selected project. See [`docs/mcp.md`](./docs/mcp.md) for the full tool reference. +For mobile and TV app testing, run the focused **XCRS Mobile & TV +Automation** profile: + +```sh +claude mcp add xcrs -- smb --mcp --scope automation +``` + +It exposes target-aware Xcode, ControlKit, CoreDevice, and adb tools without +mixing them into the cloud tool set. The same profile is also available from +the standalone `xcrs --mcp` crate. + ## CI / non-interactive mode Pass `--ci` (or set `SMB_CI=1`, or run under any provider that sets `CI`) to diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index a2bdecf..29af06e 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -1,6 +1,6 @@ use { crate::{account, cloud_auth, mail, project, tenant}, - clap::{Parser, Subcommand}, + clap::{Parser, Subcommand, ValueEnum}, smbcloud_network::environment::Environment, spinners::Spinner, std::path::PathBuf, @@ -18,6 +18,13 @@ impl CommandResult { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum McpScope { + #[default] + Cloud, + Automation, +} + #[derive(Parser)] #[clap(author, version, about)] pub struct Cli { @@ -45,6 +52,17 @@ pub struct Cli { #[arg(long, global = true)] pub mcp: bool, + /// MCP tool profile to expose. Cloud serves smbCloud resources; automation + /// serves cross-platform mobile and TV device tools. + #[arg( + long = "scope", + global = true, + value_enum, + default_value_t, + requires = "mcp" + )] + pub mcp_scope: McpScope, + #[command(subcommand)] pub command: Option, } @@ -109,6 +127,43 @@ pub enum Commands { Migrate {}, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mcp_defaults_to_cloud_scope() { + let cli = Cli::try_parse_from(["smb", "--mcp"]).expect("MCP arguments should parse"); + + assert_eq!(cli.mcp_scope, McpScope::Cloud); + } + + #[test] + fn mcp_accepts_automation_scope() { + let cli = Cli::try_parse_from(["smb", "--mcp", "--scope", "automation"]) + .expect("automation MCP arguments should parse"); + + assert_eq!(cli.mcp_scope, McpScope::Automation); + } + + #[test] + fn scope_requires_mcp_mode() { + // `Cli` has no `Debug` impl (clap's `Parser` doesn't require one), so + // `expect_err`/`unwrap_err` (which bound the `Ok` type on `Debug`) + // don't apply here; match the `Result` instead. + let result = Cli::try_parse_from(["smb", "--scope", "automation"]); + let error = match result { + Err(error) => error, + Ok(_) => panic!("scope without MCP mode should be rejected"), + }; + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } +} + #[derive(Subcommand)] pub enum ControlKitCommands { #[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index abf20e3..9d7220d 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -151,7 +151,7 @@ async fn run_mcp(cli: Cli) -> Result<()> { } else { setup_logging(cli.environment, None)?; } - smbcloud_cli::mcp::serve(cli.environment).await + smbcloud_cli::mcp::serve(cli.environment, cli.mcp_scope).await } async fn run(cli: Cli) -> Result { diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index f19ec15..4cf5e23 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -1,8 +1,9 @@ //! MCP (Model Context Protocol) server interface. //! //! When `smb` is started with `--mcp`, it runs as an MCP server over stdio -//! instead of executing a one-shot command. The server exposes smbCloud -//! operations as MCP tools built on the official `rmcp` SDK. +//! instead of executing a one-shot command. The default profile exposes +//! smbCloud operations; `--scope automation` exposes the shared XCRS mobile +//! and TV automation profile. //! //! Tools call the same library functions the CLI handlers use, but return //! structured JSON rather than rendering spinners or a TUI — the stdout stream @@ -17,6 +18,7 @@ use { crate::{ account::lib::is_logged_in, + cli::McpScope, client, mail::{ current_project::{resolve_optional_project_id, resolve_required_project_id}, @@ -978,9 +980,7 @@ impl SmbMcpServer { } } -#[tool_handler( - router = (Self::cloud_tool_router() + Self::xcrs_tool_router()) -)] +#[tool_handler(router = Self::cloud_tool_router())] impl ServerHandler for SmbMcpServer { fn get_info(&self) -> ServerInfo { // `Implementation` is `#[non_exhaustive]`, so start from the build-env @@ -995,14 +995,18 @@ impl ServerHandler for SmbMcpServer { "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \ `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \ the tenant/project context other tools default to, shared with the CLI's own \ - `smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \ - visionOS, watchOS, and macOS runners.", + `smb tenant use` / `smb project use`. Use `smb --mcp --scope automation` \ + instead for mobile and TV app automation tools.", ) } } /// Run the MCP server over stdio until the client disconnects. -pub async fn serve(environment: Environment) -> Result<()> { +pub async fn serve(environment: Environment, scope: McpScope) -> Result<()> { + if scope == McpScope::Automation { + return mcp_xcrs::serve().await; + } + let running = SmbMcpServer::new(environment) .serve(stdio()) .await @@ -1013,3 +1017,61 @@ pub async fn serve(environment: Environment) -> Result<()> { .map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloud_router_exposes_known_cloud_tools() { + let tools = SmbMcpServer::cloud_tool_router().list_all(); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + + for cloud_only in [ + "me", + "project_list", + "tenant_list", + "mail_list", + "auth_app_list", + ] { + assert!( + names.contains(&cloud_only), + "cloud router should expose {cloud_only:?}" + ); + } + } + + #[test] + fn cloud_router_excludes_automation_tools() { + let tools = SmbMcpServer::cloud_tool_router().list_all(); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + + const AUTOMATION_TOOL_NAMES: [&str; 18] = [ + "device_list", + "device_select", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set", + ]; + + for automation_only in AUTOMATION_TOOL_NAMES { + assert!( + !names.contains(&automation_only), + "cloud router should not expose automation tool {automation_only:?}" + ); + } + } +} diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index 4b2e894..d816afd 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -1,24 +1,156 @@ -use super::SmbMcpServer; +//! Automation MCP profile: `smb --mcp --scope automation` re-exposes the +//! canonical cross-platform mobile and TV automation tool set defined in the +//! `xcrs` crate, under its own [`AutomationMcpServer`]. +//! +//! This is a separate server type rather than more tools bolted onto +//! [`super::SmbMcpServer`] so a single MCP session only ever sees one +//! profile's tools — cloud or automation, never both merged together. + +use { + anyhow::{anyhow, Result}, + rmcp::{ + model::{Implementation, ServerCapabilities, ServerInfo}, + transport::stdio, + ServerHandler, ServiceExt, + }, +}; + +/// The shared XCRS mobile/TV automation MCP server. Tool names carry no +/// `smb_`/`xcrs_` prefix: the MCP client already namespaces tools by server, +/// so a prefix here would just duplicate that. +#[derive(Debug, Default)] +pub struct AutomationMcpServer; + +impl AutomationMcpServer { + pub fn new() -> Self { + Self + } +} xcrs::xcrs_mcp_tools!( - SmbMcpServer, - "smb_list_simulators", - "smb_find_simulator", - "smb_boot_and_install", - "smb_screenshot", - "smb_launch_app", - "smb_terminate_app", - "smb_open_url", - "smb_describe_ui", - "smb_list_elements", - "smb_tap", - "smb_type_text", - "smb_swipe", - "smb_button", - "smb_orientation_get", - "smb_orientation_set", - "smb_capabilities", - "smb_click", - "smb_gesture", - "smb_use_target" + AutomationMcpServer, + "device_list", + "device_select", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set" ); + +#[rmcp::tool_handler(router = Self::xcrs_tool_router())] +impl ServerHandler for AutomationMcpServer { + fn get_info(&self) -> ServerInfo { + // `Implementation` is `#[non_exhaustive]`, so start from the build-env + // default and override the identity fields. + let mut server_info = Implementation::from_build_env(); + server_info.name = "XCRS Mobile & TV Automation".to_string(); + server_info.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + .with_instructions( + "Cross-platform mobile and TV app automation over Apple simulators/ControlKit \ + and Android adb. Start with `device_list` to discover available simulators and \ + adb-connected devices, then `device_select` to remember one for later calls, \ + then `device_capabilities` to check what it supports before choosing an \ + inspection or input tool. Every action tool also accepts an inline target \ + instead of the remembered one. Use plain `smb --mcp` instead for smbCloud \ + account, project, tenant, Mail, and Auth tools.", + ) + } +} + +/// Run the automation MCP server over stdio until the client disconnects. +pub async fn serve() -> Result<()> { + let running = AutomationMcpServer::new() + .serve(stdio()) + .await + .map_err(|error| anyhow!("Failed to start automation MCP server: {error}"))?; + running + .waiting() + .await + .map_err(|error| anyhow!("Automation MCP server stopped unexpectedly: {error}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CANONICAL_TOOL_NAMES: [&str; 18] = [ + "device_list", + "device_select", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set", + ]; + + #[test] + fn automation_router_exposes_exactly_the_canonical_eighteen_tools() { + let tools = AutomationMcpServer::xcrs_tool_router().list_all(); + let mut names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + names.sort_unstable(); + + let mut expected = CANONICAL_TOOL_NAMES; + expected.sort_unstable(); + + assert_eq!(names, expected, "automation router tool set changed"); + } + + #[test] + fn automation_tool_names_carry_no_server_prefix() { + let tools = AutomationMcpServer::xcrs_tool_router().list_all(); + + for tool in &tools { + assert!( + !tool.name.starts_with("smb_") && !tool.name.starts_with("xcrs_"), + "tool name {:?} should not carry a redundant server prefix", + tool.name + ); + } + } + + #[test] + fn automation_router_excludes_cloud_tools() { + let tools = AutomationMcpServer::xcrs_tool_router().list_all(); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + + for cloud_only in [ + "me", + "project_list", + "tenant_list", + "mail_list", + "auth_app_list", + ] { + assert!( + !names.contains(&cloud_only), + "automation router should not expose cloud tool {cloud_only:?}" + ); + } + } +} diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml index 98cdc71..64b8936 100644 --- a/crates/xcrs/Cargo.toml +++ b/crates/xcrs/Cargo.toml @@ -3,11 +3,11 @@ name = "xcrs" version = "0.4.13" edition = "2021" authors = ["Seto Elkahfi "] -description = "Rust abstraction for Xcode command line tools." +description = "Cross-platform mobile and TV app automation over MCP." license = "Apache-2.0" repository = "https://github.com/smbcloudXYZ/smbcloud-cli" documentation = "https://smbcloud.xyz/posts" -keywords = ["xcode", "ios", "simulator", "cli"] +keywords = ["mcp", "mobile", "android", "ios", "automation"] categories = ["command-line-utilities", "development-tools"] [lib] diff --git a/crates/xcrs/README.md b/crates/xcrs/README.md index d84b0f5..e2fb0dd 100644 --- a/crates/xcrs/README.md +++ b/crates/xcrs/README.md @@ -1,7 +1,9 @@ -# XCRS +# XCRS Mobile & TV Automation -Rust-native Xcode command-line and Apple simulator automation tools with an -MCP server for simulator and ControlKit workflows. +Cross-platform MCP tools for testing mobile and TV apps on Apple simulators, +physical Apple devices, Android phones, and Android TV devices. XCRS combines +Xcode, ControlKit, CoreDevice, and adb behind one target-aware automation +workflow. MCP Registry name: `mcp-name: io.github.smbcloudXYZ/xcrs` @@ -12,6 +14,16 @@ cargo install xcrs xcrs --mcp ``` +Android tools use `adb` from `ANDROID_SDK_ROOT`, `ANDROID_HOME`, the standard +Android SDK locations, or `PATH`. Connect a device with USB debugging enabled +and authorize the host before selecting it with `device_select`. + +The same automation profile is available from the full smbCloud CLI: + +```sh +smb --mcp --scope automation +``` + ## Repository Source and documentation are available in the diff --git a/crates/xcrs/src/main.rs b/crates/xcrs/src/main.rs index 7521991..71edb8d 100644 --- a/crates/xcrs/src/main.rs +++ b/crates/xcrs/src/main.rs @@ -5,7 +5,7 @@ use xcrs::{IosAppTest, XcodeCommandLineTools}; #[derive(Debug, Parser)] #[command(name = "xcrs")] -#[command(about = "Rust abstraction over Xcode command line tools")] +#[command(about = "Cross-platform mobile and TV app automation")] struct Cli { /// Run as a standalone Model Context Protocol server over stdio. #[arg(long)] diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 8f0f67b..3bae78a 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -1,3 +1,19 @@ +//! Cross-platform automation MCP contract. +//! +//! This module defines the canonical tool set exposed by `xcrs` (and re-exposed by +//! embedders such as `smb --mcp --scope automation`): `device_list`, `device_select`, +//! `device_capabilities`, `app_install_launch`, `screen_capture`, `app_launch`, +//! `app_terminate`, `url_open`, `ui_describe`, `ui_element_list`, `input_tap`, +//! `input_text`, `input_swipe`, `input_button`, `input_click`, `input_spatial_tap`, +//! `orientation_get`, and `orientation_set`. Each name is supplied by the embedder +//! through [`xcrs_mcp_tools`] so the standalone crate and CLI profile share one +//! implementation and one public contract. +//! +//! Targets are Apple simulators, Apple ControlKit hosts (physical/remote devices or +//! a local macOS runner), or Android devices reachable through adb. A target can be +//! selected once with `device_select` and reused by later calls, or passed explicitly +//! on any call. [`resolve_selected_target`] is the single, unit-tested place that +//! validates a target is unambiguous. use { anyhow::{anyhow, Result}, rmcp::{ @@ -6,264 +22,601 @@ use { ServerHandler, ServiceExt, }, schemars::JsonSchema, - serde::Deserialize, + serde::{Deserialize, Serialize}, std::path::PathBuf, }; -#[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorFindArgs { - /// Exact simulator name to find. - pub name: String, +/// A validated, unambiguous automation target: exactly one of an Apple simulator, an +/// Apple ControlKit host (physical device, remote runner, or local Mac), or an +/// Android device. Produced by [`resolve_selected_target`], never constructed +/// directly from untrusted input. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[serde(tag = "platform", rename_all = "snake_case")] +pub enum SelectedTarget { + /// An Apple simulator known to `simctl`, identified by name and/or UDID. + AppleSimulator { + #[serde(default)] + simulator_name: Option, + #[serde(default)] + simulator_udid: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + controlkit_port: Option, + }, + /// A ControlKit JSON-RPC endpoint reachable over the network: a paired physical + /// device, a remote runner, or a local macOS app. + AppleHost { + host: String, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + controlkit_port: Option, + }, + /// An Android device or emulator reachable through adb. + Android { + /// Device serial from `adb devices -l`. `None` means "the single + /// authorized device", resolved lazily when the target is used. + #[serde(default)] + serial: Option, + }, } -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ScreenshotArgs { - /// Simulator name of the target to capture. Omit to use the target selected - /// with the use-target tool. - #[serde(default)] - pub simulator_name: Option, - /// Simulator UDID of the target to capture. - #[serde(default)] - pub simulator_udid: Option, - /// Physical device identifier known to CoreDevice (UDID, ECID, serial - /// number, user-provided name, or DNS name). Provide this to capture a - /// paired physical device instead of a simulator. - #[serde(default)] - pub device: Option, +/// Validate and tag a target from optional per-call fields, falling back to +/// `stored` (the target previously selected with `device_select`) when none of the +/// fields are provided. Returns a plain error message (rather than an MCP error +/// type) so this function stays easy to unit test; callers wrap it for their +/// transport. +/// +/// Rejects ambiguous combinations: `android_serial` together with any Apple field, +/// or `simulator_name`/`simulator_udid` together with `host`. +pub fn resolve_selected_target( + simulator_name: Option, + simulator_udid: Option, + host: Option, + controlkit_port: Option, + android_serial: Option, + stored: Option, +) -> Result { + let apple_simulator_provided = simulator_name.is_some() || simulator_udid.is_some(); + let apple_host_provided = host.is_some(); + let android_provided = android_serial.is_some(); + + if android_provided && (apple_simulator_provided || apple_host_provided) { + return Err( + "Ambiguous target: android_serial cannot be combined with simulator_name, \ + simulator_udid, or host. Provide exactly one target." + .to_string(), + ); + } + if apple_simulator_provided && apple_host_provided { + return Err( + "Ambiguous target: simulator_name/simulator_udid cannot be combined with host. \ + Provide exactly one target." + .to_string(), + ); + } + + if let Some(serial) = android_serial { + return Ok(SelectedTarget::Android { + serial: Some(serial), + }); + } + if let Some(host) = host { + return Ok(SelectedTarget::AppleHost { + host, + controlkit_port, + }); + } + if apple_simulator_provided { + return Ok(SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + controlkit_port, + }); + } + + stored.ok_or_else(|| { + "No target provided. Pass simulator_name/simulator_udid, host, or android_serial, \ + or select a default target first with device_select." + .to_string() + }) +} + +/// Reject a resolved target that is Android for a tool that only works on Apple +/// platforms (UI introspection, orientation, macOS click, visionOS spatial tap). +/// Returns a message naming the offending tool so the error is actionable. +pub fn require_apple_target(tool_name: &str, target: &SelectedTarget) -> Result<(), String> { + match target { + SelectedTarget::Android { serial } => Err(format!( + "{tool_name} is Apple-only; the active target is an Android device ({}). \ + Select an Apple simulator or ControlKit host with device_select, or pass \ + simulator_name, simulator_udid, or host explicitly.", + serial.as_deref().unwrap_or("auto-detected") + )), + SelectedTarget::AppleSimulator { .. } | SelectedTarget::AppleHost { .. } => Ok(()), + } +} + +/// Round a floating-point coordinate to the integer pixel value adb's `input` +/// commands require, rejecting non-finite values instead of silently truncating. +pub fn to_android_coordinate(value: f64) -> Result { + if !value.is_finite() { + return Err(format!("coordinate {value} is not a finite number")); + } + Ok(value.round() as i32) +} + +/// tvOS/iOS/watchOS remote and hardware buttons reachable through ControlKit's +/// `device.io.button` method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppleButton { + Up, + Down, + Left, + Right, + Select, + Menu, + Home, + PlayPause, +} + +impl AppleButton { + pub const ALL: [&'static str; 8] = [ + "up", + "down", + "left", + "right", + "select", + "menu", + "home", + "playPause", + ]; + + pub fn parse(value: &str) -> Option { + match value { + "up" => Some(Self::Up), + "down" => Some(Self::Down), + "left" => Some(Self::Left), + "right" => Some(Self::Right), + "select" => Some(Self::Select), + "menu" => Some(Self::Menu), + "home" => Some(Self::Home), + "playPause" => Some(Self::PlayPause), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + Self::Select => "select", + Self::Menu => "menu", + Self::Home => "home", + Self::PlayPause => "playPause", + } + } +} + +/// Android system buttons reachable through `adb shell input keyevent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AndroidButton { + Home, + Back, + Enter, + Recents, +} + +impl AndroidButton { + pub const ALL: [&'static str; 4] = ["home", "back", "enter", "recents"]; + + pub fn parse(value: &str) -> Option { + match value { + "home" => Some(Self::Home), + "back" => Some(Self::Back), + "enter" => Some(Self::Enter), + "recents" => Some(Self::Recents), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Home => "home", + Self::Back => "back", + Self::Enter => "enter", + Self::Recents => "recents", + } + } +} + +/// The button-name vocabulary accepted by `input_button`, spanning both +/// platforms so the tool has a single typed, enumerable field instead of a free +/// string. Which subset is actually valid depends on the resolved target's +/// platform: Apple targets accept `up`, `down`, `left`, `right`, `select`, +/// `menu`, `home`, or `playPause`; Android targets accept `home`, `back`, +/// `enter`, or `recents`. `home` is valid on both. Passing a value from the +/// wrong platform's vocabulary for the resolved target is rejected at call time +/// with an error naming the valid set for that platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ButtonName { + Up, + Down, + Left, + Right, + Select, + Menu, + Home, + PlayPause, + Back, + Enter, + Recents, +} + +impl ButtonName { + pub fn as_str(self) -> &'static str { + match self { + Self::Up => "up", + Self::Down => "down", + Self::Left => "left", + Self::Right => "right", + Self::Select => "select", + Self::Menu => "menu", + Self::Home => "home", + Self::PlayPause => "playPause", + Self::Back => "back", + Self::Enter => "enter", + Self::Recents => "recents", + } + } +} + +/// Screen orientation accepted by `orientation_set`. Apple-only: ControlKit is the +/// only backend that implements orientation control today. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +#[serde(rename_all = "UPPERCASE")] +pub enum Orientation { + Portrait, + Landscape, +} + +impl Orientation { + pub fn as_str(self) -> &'static str { + match self { + Self::Portrait => "PORTRAIT", + Self::Landscape => "LANDSCAPE", + } + } +} + +/// Static, hand-maintained capability description for Android targets. Unlike +/// Apple's `device.capabilities` ControlKit call, adb has no capability-discovery +/// RPC, so this mirrors exactly the actions `AndroidDebugBridge` implements. +pub fn android_capabilities() -> serde_json::Value { + serde_json::json!({ + "screen_capture": true, + "app_launch": { + "supported": true, + "note": "Resolves the Leanback (Android TV) launcher activity first, \ + then falls back to the standard launcher activity.", + }, + "app_terminate": true, + "url_open": true, + "input_tap": true, + "input_text": true, + "input_swipe": true, + "input_button": { + "supported": true, + "buttons": AndroidButton::ALL, + }, + "app_install_launch": false, + "ui_describe": false, + "ui_element_list": false, + "orientation_get": false, + "orientation_set": false, + "input_click": false, + "input_spatial_tap": false, + }) } -/// A target chosen once with the use-target tool and reused by later actions so -/// they no longer need per-call target arguments. -#[derive(Debug, Clone, Default, Deserialize, JsonSchema, serde::Serialize)] -pub struct StoredTarget { +/// Target-only arguments shared by `device_select` and `device_capabilities`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeviceSelectArgs { + /// Exact Apple simulator name to remember as the active target. Mutually + /// exclusive with `host` and `android_serial`. #[serde(default)] pub simulator_name: Option, + /// Apple simulator UDID to remember as the active target. Mutually exclusive + /// with `host` and `android_serial`. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host of a physical device, remote runner, or local Mac to + /// remember. Mutually exclusive with `simulator_name`/`simulator_udid` and + /// `android_serial`. #[serde(default)] pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. Only meaningful with + /// `simulator_name`/`simulator_udid` or `host`. #[serde(default)] pub controlkit_port: Option, + /// Android device serial from `adb devices -l` to remember as the active + /// target. Mutually exclusive with every Apple field. + #[serde(default)] + pub android_serial: Option, +} + +/// Structured result of `device_select`: the single unambiguous target that is +/// now remembered for later calls, until overridden by another `device_select` +/// call or by explicit target fields on a later call. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct DeviceSelectOutput { + pub selected: SelectedTarget, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct UseTargetArgs { - /// Exact simulator name to remember as the active target. +pub struct DeviceCapabilitiesArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID to remember as the active target. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host of a physical device or remote runner to remember. + /// ControlKit host of a physical device, remote runner, or local Mac. #[serde(default)] pub host: Option, - /// Local ControlKit JSON-RPC port to remember. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct IosAppTestArgs { - /// Exact simulator name to use. +pub struct AppInstallLaunchArgs { + /// Exact simulator name to use. Provide this or `simulator_udid`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID to use. + /// Simulator UDID to use. Provide this or `simulator_name`. #[serde(default)] pub simulator_udid: Option, - /// Path to the `.app` bundle. + /// Path to the `.app` bundle on disk. pub app_path: PathBuf, - /// Installed app bundle identifier. + /// Bundle identifier of the app inside `app_path`. pub bundle_id: String, - /// Terminate the app before launching it. + /// Terminate a previously running instance of the app before launching it. #[serde(default)] pub terminate_before_launch: bool, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorTargetArgs { - /// Exact simulator name. +pub struct ScreenCaptureArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host. Screen capture is not implemented for ControlKit hosts; + /// use `device` instead for a physical device, or select a simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, + /// Physical device identifier known to CoreDevice (UDID, ECID, serial number, + /// user-provided name, or DNS name). Provide this to capture a paired physical + /// Apple device instead of a simulator. Mutually exclusive with every other + /// field. + #[serde(default)] + pub device: Option, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorAppArgs { - /// Exact simulator name. +pub struct AppTargetArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, - /// App bundle identifier. - pub bundle_id: String, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// App identifier: an Apple bundle identifier, or an Android package name, + /// depending on the resolved target platform. + pub app_id: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorOpenUrlArgs { - /// Exact simulator name. +pub struct UrlOpenArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host. Opening URLs is not implemented for ControlKit hosts; + /// use a simulator or an Android target instead. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, - /// HTTP(S) URL or custom URL scheme. + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// HTTP(S) URL or custom URL scheme to open. pub url: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorTapArgs { - /// Exact simulator name. +pub struct TapArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, - /// Horizontal screen coordinate. - pub x: f32, - /// Vertical screen coordinate. - pub y: f32, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// Horizontal screen coordinate in points (Apple) or pixels (Android). + pub x: f64, + /// Vertical screen coordinate in points (Apple) or pixels (Android). + pub y: f64, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorTextArgs { - /// Exact simulator name. +pub struct TextArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, - /// Text to type into the focused field. + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// Single-line text to type into the focused field. pub text: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorSwipeArgs { - /// Exact simulator name. +pub struct SwipeArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, /// Swipe start horizontal coordinate. - pub x1: i32, + pub x1: f64, /// Swipe start vertical coordinate. - pub y1: i32, + pub y1: f64, /// Swipe end horizontal coordinate. - pub x2: i32, + pub x2: f64, /// Swipe end vertical coordinate. - pub y2: i32, -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorOrientationArgs { - /// Exact simulator name. - #[serde(default)] - pub simulator_name: Option, - /// Simulator UDID. - #[serde(default)] - pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. - #[serde(default)] - pub host: Option, - /// Local ControlKit JSON-RPC port. Defaults to 12004. + pub y2: f64, + /// Swipe duration in milliseconds. Defaults to 300. Android only; Apple + /// ControlKit swipes ignore this field. #[serde(default)] - pub controlkit_port: Option, - /// Desired orientation: PORTRAIT or LANDSCAPE. - pub orientation: String, + pub duration_ms: Option, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorControlKitArgs { - /// Exact simulator name. +pub struct ButtonArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// Button to press. For Apple targets: up, down, left, right, select, menu, + /// home, or playPause. For Android targets: home, back, enter, or recents. + /// Passing a value from the wrong platform's vocabulary is rejected with a + /// clear error naming the valid set for the resolved target. + pub button: ButtonName, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorButtonArgs { - /// Exact simulator name. +pub struct UiTargetArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + /// ControlKit host of a physical device or remote runner. Omit for a local + /// simulator. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, - /// tvOS button: up, down, left, right, select, menu, home, or playPause. - pub button: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct ControlKitEndpointArgs { - /// Exact simulator name. Omit this when connecting to a local macOS runner. +pub struct OrientationSetArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. Omit this when connecting to a local macOS runner. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host. Defaults to 127.0.0.1 for local runners. + /// ControlKit host of a physical device or remote runner. Omit for a local + /// simulator. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, + /// Desired screen orientation. + pub orientation: Orientation, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct ControlKitCoordinateArgs { - /// Exact simulator name. Omit this when connecting to a local macOS runner. +pub struct CoordinateArgs { + /// Exact Apple simulator name. Omit to use the target selected with + /// `device_select`. #[serde(default)] pub simulator_name: Option, - /// Simulator UDID. Omit this when connecting to a local macOS runner. + /// Apple simulator UDID. Omit to use the target selected with `device_select`. #[serde(default)] pub simulator_udid: Option, - /// ControlKit host. Defaults to 127.0.0.1 for local runners. + /// ControlKit host of a physical device or remote runner. Omit for a local + /// simulator or Mac. #[serde(default)] pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. @@ -288,230 +641,254 @@ impl XcrsMcpServer { macro_rules! xcrs_mcp_tools { ( $server:ty, - $simulator_list_name:literal, - $simulator_find_name:literal, - $ios_app_test_name:literal, - $simulator_screenshot_name:literal, - $simulator_launch_app_name:literal, - $simulator_terminate_app_name:literal, - $simulator_open_url_name:literal, - $simulator_ui_dump_name:literal, - $simulator_list_elements_name:literal, - $simulator_tap_name:literal, - $simulator_text_name:literal, - $simulator_swipe_name:literal, - $simulator_button_name:literal, - $simulator_orientation_get_name:literal, - $simulator_orientation_set_name:literal, - $controlkit_capabilities_name:literal, - $macos_click_name:literal, - $visionos_spatial_tap_name:literal, - $use_target_name:literal + $device_list_name:literal, + $device_select_name:literal, + $device_capabilities_name:literal, + $app_install_launch_name:literal, + $screen_capture_name:literal, + $app_launch_name:literal, + $app_terminate_name:literal, + $url_open_name:literal, + $ui_describe_name:literal, + $ui_element_list_name:literal, + $input_tap_name:literal, + $input_text_name:literal, + $input_swipe_name:literal, + $input_button_name:literal, + $input_click_name:literal, + $input_spatial_tap_name:literal, + $orientation_get_name:literal, + $orientation_set_name:literal ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { fn selected_target_slot( - ) -> &'static ::std::sync::Mutex> { - static SLOT: ::std::sync::Mutex> = + ) -> &'static ::std::sync::Mutex> { + static SLOT: ::std::sync::Mutex> = ::std::sync::Mutex::new(None); &SLOT } - fn stored_target() -> Option<$crate::mcp::StoredTarget> { + fn stored_target() -> Option<$crate::mcp::SelectedTarget> { Self::selected_target_slot() .lock() .ok() .and_then(|slot| slot.clone()) } - /// Fill missing target arguments from the target selected with the - /// use-target tool so per-call target fields become optional. - fn resolve_target_fields( - simulator_name: &Option, - simulator_udid: &Option, - host: &Option, + /// Validate and tag a target from per-call fields, falling back to the + /// target selected with `device_select` when no field is provided. + fn dispatch_target( + simulator_name: Option, + simulator_udid: Option, + host: Option, controlkit_port: Option, - ) -> (Option, Option, Option, Option) { - if simulator_name.is_none() && simulator_udid.is_none() && host.is_none() { - if let Some(stored) = Self::stored_target() { - return ( - stored.simulator_name, - stored.simulator_udid, - stored.host, - controlkit_port.or(stored.controlkit_port), - ); - } - } - ( - simulator_name.clone(), - simulator_udid.clone(), - host.clone(), + android_serial: Option, + ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData> + { + $crate::mcp::resolve_selected_target( + simulator_name, + simulator_udid, + host, controlkit_port, + android_serial, + Self::stored_target(), ) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None)) } - fn simulator_from_target( - target: &$crate::mcp::SimulatorTargetArgs, + /// Like [`Self::dispatch_target`] but for tools that only support Apple + /// targets; rejects an Android target (explicit or stored) with a clear + /// error naming `tool_name`. + fn dispatch_apple_target( + tool_name: &str, + simulator_name: Option, + simulator_udid: Option, + host: Option, + controlkit_port: Option, + ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData> + { + let target = + Self::dispatch_target(simulator_name, simulator_udid, host, controlkit_port, None)?; + $crate::mcp::require_apple_target(tool_name, &target) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; + Ok(target) + } + + fn simulator_for( + simulator_name: &Option, + simulator_udid: &Option, ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> { let tools = $crate::XcodeCommandLineTools::new(); - let (simulator_name, simulator_udid, _host, _port) = Self::resolve_target_fields( - &target.simulator_name, - &target.simulator_udid, - &None, - target.controlkit_port, - ); - match (&simulator_udid, &simulator_name) { - (Some(udid), _) => tools - .simctl() - .find_simulator_by_udid(udid) - .map_err(|error| { - ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) - }), - (None, Some(name)) => tools - .simctl() - .find_simulator_by_name(name) - .map_err(|error| { - ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) - }), + match (simulator_udid, simulator_name) { + (Some(udid), _) => tools.simctl().find_simulator_by_udid(udid).map_err( + |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None), + ), + (None, Some(name)) => tools.simctl().find_simulator_by_name(name).map_err( + |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None), + ), (None, None) => Err(::rmcp::model::ErrorData::invalid_request( - "No target. Pass simulator_name or simulator_udid, or select one first with the use-target tool.", + "Apple target requires simulator_name or simulator_udid.", None, )), } } - fn controlkit_from_target( - simulator_name: &Option, - simulator_udid: &Option, - host: &Option, - controlkit_port: Option, + /// Build the ControlKit client and, when applicable, resolve the + /// simulator for an Apple target. Callers must not pass an Android + /// target; use `dispatch_apple_target` or branch on the target first. + fn controlkit_for_target( + target: &$crate::mcp::SelectedTarget, ) -> ::std::result::Result< (Option<$crate::Simulator>, $crate::ControlKit), ::rmcp::model::ErrorData, > { - let (simulator_name, simulator_udid, host, controlkit_port) = - Self::resolve_target_fields( - simulator_name, - simulator_udid, + match target { + $crate::mcp::SelectedTarget::AppleHost { host, controlkit_port, - ); - if let Some(host) = host { - return Ok(( + } => Ok(( None, - $crate::ControlKit::with_host(&host, controlkit_port.unwrap_or(12004)), - )); + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)), + )), + $crate::mcp::SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + controlkit_port, + } => { + let simulator = Self::simulator_for(simulator_name, simulator_udid)?; + Ok(( + Some(simulator), + $crate::ControlKit::new(controlkit_port.unwrap_or(12004)), + )) + } + $crate::mcp::SelectedTarget::Android { .. } => { + Err(::rmcp::model::ErrorData::invalid_request( + "Android targets do not use ControlKit.", + None, + )) + } } - let target = $crate::mcp::SimulatorTargetArgs { - simulator_name, - simulator_udid, - controlkit_port, - }; - let simulator = Self::simulator_from_target(&target)?; - Ok(( - Some(simulator), - $crate::ControlKit::new(controlkit_port.unwrap_or(12004)), - )) } - fn target_label(simulator: &Option<$crate::Simulator>, host: &Option) -> String { - if let Some(simulator) = simulator { - return simulator.name.clone(); + async fn run_android_blocking( + operation: F, + ) -> ::anyhow::Result + where + T: Send + 'static, + F: FnOnce() -> ::anyhow::Result + Send + 'static, + { + ::tokio::task::spawn_blocking(operation) + .await + .map_err(|error| ::anyhow::anyhow!("Android adb task failed: {error}"))? + } + + async fn android_device_for( + serial: Option, + ) -> ::std::result::Result<$crate::AndroidDevice, ::rmcp::model::ErrorData> { + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new().resolve_device(serial.as_deref()) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + }) + } + + fn target_label( + target: &$crate::mcp::SelectedTarget, + simulator: &Option<$crate::Simulator>, + ) -> String { + match target { + $crate::mcp::SelectedTarget::Android { serial } => serial + .clone() + .unwrap_or_else(|| "the connected Android device".to_string()), + $crate::mcp::SelectedTarget::AppleHost { host, .. } => host.clone(), + $crate::mcp::SelectedTarget::AppleSimulator { .. } => simulator + .as_ref() + .map(|simulator| simulator.name.clone()) + .unwrap_or_else(|| "the selected simulator".to_string()), } - host.clone().unwrap_or_else(|| "127.0.0.1".to_string()) } - fn controlkit_from_endpoint( - endpoint: &$crate::mcp::ControlKitEndpointArgs, + #[::rmcp::tool( + name = $device_list_name, + title = "List devices", + annotations(title = "List devices", read_only_hint = true, idempotent_hint = true), + description = "Purpose: enumerate every automation-capable device known to this machine in one normalized list, across Apple and Android. When to use vs siblings: call this first to discover what is available, then remember a choice with device_select; use device_capabilities afterwards to check what a specific target supports before driving it. Behavior: lists every Apple simulator known to Xcode via `simctl` (platform, runtime, UDID, availability, and boot state) plus every Android device or emulator known to `adb devices -l` (serial, model, and authorization/connection state), normalized into entries with platform, kind, identifier, name, state, and transport fields. Prerequisites: Xcode command line tools and/or adb must be installed and on PATH; neither is required for the other platform's entries to appear. Failure modes: if one platform's tool is missing or errors, that platform's entries are omitted and the error is reported under an `errors` object keyed by platform name, while the other platform's entries are still returned. Limitations: Apple physical (non-simulator) devices are not listed here because this crate has no reliable device-discovery command for them; only simulators are enumerated for Apple. Screenshot support for a paired physical Apple device still works through screen_capture's `device` parameter even though it will not appear in this list." + )] + async fn device_list( + &self, ) -> ::std::result::Result< - ($crate::ControlKit, Option<$crate::Simulator>), + ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator_name, simulator_udid, host, controlkit_port) = - Self::resolve_target_fields( - &endpoint.simulator_name, - &endpoint.simulator_udid, - &endpoint.host, - endpoint.controlkit_port, - ); - let simulator = match (&simulator_udid, &simulator_name) { - (Some(udid), _) => Some( - $crate::XcodeCommandLineTools::new() - .simctl() - .find_simulator_by_udid(udid) - .map_err(|error| { - ::rmcp::model::ErrorData::invalid_request( - error.to_string(), - None, - ) - })?, - ), - (None, Some(name)) => Some( - $crate::XcodeCommandLineTools::new() - .simctl() - .find_simulator_by_name(name) - .map_err(|error| { - ::rmcp::model::ErrorData::invalid_request( - error.to_string(), - None, - ) - })?, - ), - (None, None) => None, - }; - let host = host.unwrap_or_else(|| "127.0.0.1".to_string()); - Ok(( - $crate::ControlKit::with_host( - &host, - controlkit_port.unwrap_or(12004), + let mut devices = Vec::new(); + let mut errors = ::serde_json::Map::new(); + + match $crate::XcodeCommandLineTools::new().simctl().list_simulators() { + Ok(simulators) => devices.extend( + simulators + .iter() + .map($crate::NormalizedDevice::from_simulator), ), - simulator, - )) - } + Err(error) => { + errors.insert("apple".to_string(), ::serde_json::json!(error.to_string())); + } + } - fn endpoint_from_coordinate( - args: &$crate::mcp::ControlKitCoordinateArgs, - ) -> $crate::mcp::ControlKitEndpointArgs { - $crate::mcp::ControlKitEndpointArgs { - simulator_name: args.simulator_name.clone(), - simulator_udid: args.simulator_udid.clone(), - host: args.host.clone(), - controlkit_port: args.controlkit_port, + match Self::run_android_blocking(|| { + $crate::AndroidDebugBridge::new().list_devices() + }) + .await + { + Ok(android_devices) => devices.extend( + android_devices + .iter() + .map($crate::NormalizedDevice::from_android_device), + ), + Err(error) => { + errors.insert("android".to_string(), ::serde_json::json!(error.to_string())); + } } + + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "devices": devices, + "errors": errors, + }))?, + ])) } #[::rmcp::tool( - name = $use_target_name, - description = "Select the Apple target (simulator or physical/remote device) that later UI actions use by default, so you don't repeat target arguments on every call. Pass a simulator_name/simulator_udid for a simulator, or a host (plus optional controlkit_port) for a physical device or remote runner. Call this once after picking a target with the list-simulators tool; individual actions can still override it with their own target arguments." + name = $device_select_name, + title = "Select active device", + annotations(title = "Select active device", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + output_schema = ::rmcp::handler::server::tool::schema_for_type::<$crate::mcp::DeviceSelectOutput>(), + description = "Purpose: remember one target for later calls so they don't need repeated target arguments. When to use vs siblings: call this once after picking a target from device_list; every other action tool accepts the same target fields per-call and will use this stored choice only when none of them are passed, and any explicit field on a later call overrides the stored choice for that single call. Behavior: accepts exactly one of (a) simulator_name and/or simulator_udid for an Apple simulator, (b) host (with optional controlkit_port) for an Apple ControlKit endpoint such as a paired physical device, a remote runner, or a local Mac, or (c) android_serial for an Android device or emulator. Prerequisites: none beyond having a reachable target; this tool stores the selection without connecting to it. Failure modes: returns an error if no field is provided, or if fields from more than one of the three target kinds are combined (e.g. simulator_name with host, or android_serial with host)." )] - async fn use_target( + async fn device_select( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::UseTargetArgs, + $crate::mcp::DeviceSelectArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - if args.simulator_name.is_none() - && args.simulator_udid.is_none() - && args.host.is_none() - { - return Err(::rmcp::model::ErrorData::invalid_request( - "Provide a simulator_name/simulator_udid or a host to select as the active target.", - None, - )); - } - let stored = $crate::mcp::StoredTarget { - simulator_name: args.simulator_name, - simulator_udid: args.simulator_udid, - host: args.host, - controlkit_port: args.controlkit_port, - }; + let target = $crate::mcp::resolve_selected_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + None, + ) + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?; match Self::selected_target_slot().lock() { - Ok(mut slot) => *slot = Some(stored.clone()), + Ok(mut slot) => *slot = Some(target.clone()), Err(_) => { return Err(::rmcp::model::ErrorData::internal_error( "Failed to store the selected target.", @@ -519,569 +896,804 @@ macro_rules! xcrs_mcp_tools { )) } } - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&::serde_json::json!({ - "selected": stored, - }))?, - ])) + let output = $crate::mcp::DeviceSelectOutput { selected: target }; + let value = ::serde_json::to_value(&output).map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::structured(value)) } #[::rmcp::tool( - name = $controlkit_capabilities_name, - description = "Report the platform and supported input capabilities of the active Apple target. Use this to discover whether the target supports taps, spatial taps, buttons, or orientation before driving it. Works on a simulator, a physical/remote device, or the local Mac." + name = $device_capabilities_name, + title = "Get device capabilities", + annotations(title = "Get device capabilities", read_only_hint = true, idempotent_hint = true), + description = "Purpose: report which automation actions the resolved target supports before driving it. When to use vs siblings: call this after device_select (or with explicit target fields) and before UI actions, to confirm e.g. that orientation control or UI introspection is available on this target. Behavior: for an Apple target, forwards to the target's ControlKit `device.capabilities` JSON-RPC method and returns its raw result alongside the resolved simulator (if any); for an Android target, returns a static capability description reflecting exactly the adb actions this crate implements (screen capture, app launch/terminate, URL open, tap/text/swipe/button), since adb has no capability-discovery RPC. Prerequisites: for Apple, a reachable ControlKit endpoint (local companion app for a simulator, or the given host) must be running; for Android, adb must be able to reach the resolved device. Failure modes: returns an error if no target can be resolved, or if the ControlKit call fails or times out." )] - async fn controlkit_capabilities( + async fn device_capabilities( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitEndpointArgs, + $crate::mcp::DeviceCapabilitiesArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (controlkit, simulator) = Self::controlkit_from_endpoint(&args)?; - let result = controlkit - .call("device.capabilities", ::serde_json::json!({})) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&::serde_json::json!({ - "simulator": simulator, - "capabilities": result, - }))?, - ])) + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + )?; + match &target { + $crate::mcp::SelectedTarget::Android { .. } => { + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "platform": "android", + "capabilities": $crate::mcp::android_capabilities(), + }))?, + ])) + } + _ => { + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + let result = controlkit + .call("device.capabilities", ::serde_json::json!({})) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "platform": "apple", + "simulator": simulator, + "capabilities": result, + }))?, + ])) + } + } } #[::rmcp::tool( - name = $macos_click_name, - description = "Click the running macOS app at screen coordinates. Use this for macOS targets; use the tap tool for iOS/tvOS/watchOS and the gesture tool for visionOS." + name = $app_install_launch_name, + title = "Install and launch iOS app", + annotations(title = "Install and launch iOS app", read_only_hint = false, destructive_hint = true, idempotent_hint = false), + description = "Purpose: one-shot end-to-end setup for a freshly built iOS app: boot the simulator, install the .app bundle, optionally terminate a previous instance, launch it, and return its app container path. When to use vs siblings: use this once to get a build under test onto a simulator; use app_launch/app_terminate afterwards for an already-installed app, and screen_capture/ui_describe to inspect it. Behavior: boots the target simulator if it is not already booted, installs app_path, optionally force-terminates bundle_id first, launches bundle_id, then reads back the app's container directory. Prerequisites: Xcode command line tools installed; app_path must point to an existing .app bundle built for the simulator (not a device) architecture. Failure modes: errors if neither simulator_name nor simulator_udid is given, if the simulator cannot be found, or if any underlying `simctl` step fails. Limitations: iOS-simulator-only; there is no Android or physical-device equivalent in this tool." )] - async fn macos_click( + async fn app_install_launch( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitCoordinateArgs, + $crate::mcp::AppInstallLaunchArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let endpoint = Self::endpoint_from_coordinate(&args); - let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; - controlkit - .call( - "device.io.click", - ::serde_json::json!({ "x": args.x, "y": args.y }), - ) - .await + if args.simulator_name.is_none() && args.simulator_udid.is_none() { + return Err(::rmcp::model::ErrorData::invalid_request( + "Provide either simulator_name or simulator_udid.", + None, + )); + } + + let result = $crate::XcodeCommandLineTools::new() + .run_ios_app_test(&$crate::IosAppTest { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + app_path: args.app_path, + bundle_id: args.bundle_id, + terminate_before_launch: args.terminate_before_launch, + }) .map_err(|error| { ::rmcp::model::ErrorData::internal_error(error.to_string(), None) })?; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Clicked ({}, {}).", - args.x, args.y - )), + ::rmcp::model::ContentBlock::json(&result)?, ])) } #[::rmcp::tool( - name = $visionos_spatial_tap_name, - description = "Perform a spatial tap on the running visionOS app at screen coordinates. Use this for visionOS targets; use the tap tool for iOS/tvOS/watchOS and the click tool for macOS." + name = $screen_capture_name, + title = "Capture screenshot", + annotations(title = "Capture screenshot", read_only_hint = true, idempotent_hint = true), + description = "Purpose: capture a PNG screenshot of the resolved target's current screen. When to use vs siblings: use this for a pixel image; use ui_describe or ui_element_list instead when you need element coordinates or labels rather than pixels. Behavior: for an Apple simulator, captures through `simctl io screenshot`; for a paired physical Apple device, pass its CoreDevice identifier as `device` to capture through `devicectl` (this bypasses simulator/host/android_serial resolution entirely and is mutually exclusive with them); for an Android device, captures through `adb exec-out screencap`. Prerequisites: the target must be booted/connected and, for simulators, `simctl` must be able to reach it. Failure modes: returns an error if `device` is combined with any other target field, if the resolved target is an Apple ControlKit host (screenshot over ControlKit is not implemented; use `device` or select a simulator instead), or if the underlying capture command fails." )] - async fn visionos_spatial_tap( + async fn screen_capture( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitCoordinateArgs, + $crate::mcp::ScreenCaptureArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let endpoint = Self::endpoint_from_coordinate(&args); - let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; - controlkit - .call( - "device.io.spatial.tap", - ::serde_json::json!({ "x": args.x, "y": args.y }), - ) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + if let Some(device) = &args.device { + if args.simulator_name.is_some() + || args.simulator_udid.is_some() + || args.host.is_some() + || args.android_serial.is_some() + { + return Err(::rmcp::model::ErrorData::invalid_request( + "device cannot be combined with simulator_name, simulator_udid, host, or android_serial. Provide exactly one target.", + None, + )); + } + let screenshot = $crate::XcodeCommandLineTools::new() + .devicectl() + .screenshot(device) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + return Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::image( + $crate::encode_base64(screenshot), + "image/png", + ), + ])); + } + + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + )?; + let screenshot = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new().screenshot(&device_serial) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })? + } + $crate::mcp::SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + .. + } => { + let simulator = Self::simulator_for(simulator_name, simulator_udid)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .screenshot(&simulator.udid) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })? + } + $crate::mcp::SelectedTarget::AppleHost { host, .. } => { + return Err(::rmcp::model::ErrorData::invalid_request( + format!( + "screen_capture over a ControlKit host ({host}) is not supported; pass the physical device's CoreDevice identifier as `device`, or select an Apple simulator or Android device instead." + ), + None, + )); + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Performed spatial tap at ({}, {}).", - args.x, args.y - )), + ::rmcp::model::ContentBlock::image( + $crate::encode_base64(screenshot), + "image/png", + ), ])) } #[::rmcp::tool( - name = $simulator_list_name, - description = "List every Apple simulator known to Xcode with its platform, runtime, UDID, availability, and current state. Start here to pick a simulator, then remember it with the use-target tool." + name = $app_launch_name, + title = "Launch app", + annotations(title = "Launch app", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: launch an already-installed app by identifier on the resolved target. When to use vs siblings: use app_install_launch instead for a fresh iOS build that still needs installing; use this once the app is already on the device. Behavior: for an Apple simulator, launches through `simctl launch`; for an Apple ControlKit host, calls `device.apps.launch`; for Android, resolves (or prefers, if given) the launcher activity — trying the Android TV Leanback launcher category first, then the standard launcher category — and starts it. Prerequisites: app_id must already be installed on the resolved target. Failure modes: errors if no target can be resolved, if the app is not installed, or (Android) if no launchable activity is found for app_id." )] - async fn simulator_list( + async fn app_launch( &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::AppTargetArgs, + >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let simulators = $crate::XcodeCommandLineTools::new() - .simctl() - .list_simulators() - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&simulators)?, - ])) - } - - #[::rmcp::tool( - name = $simulator_find_name, - description = "Find an Apple simulator by its exact name and return its details as JSON. Use this to resolve a UDID before selecting it with the use-target tool." - )] - async fn simulator_find( - &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorFindArgs, - >, - ) -> ::std::result::Result< - ::rmcp::model::CallToolResult, - ::rmcp::model::ErrorData, - > { - let simulator = $crate::XcodeCommandLineTools::new() - .simctl() - .find_simulator_by_name(&args.name) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&simulator)?, - ])) - } - - #[::rmcp::tool( - name = $ios_app_test_name, - description = "Boot an iOS simulator, install an .app bundle, optionally terminate it first, launch it, and return its app container path. Simulator-only end-to-end setup step; use launch-app/terminate-app for an already-installed app." - )] - async fn ios_app_test( - &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::IosAppTestArgs, - >, - ) -> ::std::result::Result< - ::rmcp::model::CallToolResult, - ::rmcp::model::ErrorData, - > { - if args.simulator_name.is_none() && args.simulator_udid.is_none() { - return Err(::rmcp::model::ErrorData::invalid_request( - "Provide either simulator_name or simulator_udid.", - None, - )); - } - - let result = $crate::XcodeCommandLineTools::new() - .run_ios_app_test(&$crate::IosAppTest { - simulator_name: args.simulator_name, - simulator_udid: args.simulator_udid, - app_path: args.app_path, - bundle_id: args.bundle_id, - terminate_before_launch: args.terminate_before_launch, - }) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + )?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + let app_id = args.app_id.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new() + .launch_app(&device_serial, &app_id) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + device.serial + } + $crate::mcp::SelectedTarget::AppleHost { + host, + controlkit_port, + } => { + let controlkit = + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.launch", + ::serde_json::json!({ "bundleId": args.app_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + host.clone() + } + $crate::mcp::SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + .. + } => { + let simulator = Self::simulator_for(simulator_name, simulator_udid)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .launch_app(&simulator.udid, &args.app_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + simulator.name + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&result)?, + ::rmcp::model::ContentBlock::text(format!( + "Launched {} on {}.", + args.app_id, label + )), ])) } #[::rmcp::tool( - name = $simulator_screenshot_name, - description = "Capture a PNG screenshot of the running app. Targets a simulator (via simulator_name/simulator_udid or the selected target) or a paired physical device (via the device identifier). Use describe-ui instead when you need element coordinates rather than pixels." + name = $app_terminate_name, + title = "Terminate app", + annotations(title = "Terminate app", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: force-stop an installed app by identifier on the resolved target. When to use vs siblings: pair with app_launch to reset app state between test steps. Behavior: for an Apple simulator, terminates through `simctl terminate`; for an Apple ControlKit host, calls `device.apps.terminate`; for Android, force-stops through `am force-stop`. Prerequisites: none beyond a reachable target; terminating an app that is not running is not an error. Failure modes: errors if no target can be resolved or the underlying command fails." )] - async fn simulator_screenshot( + async fn app_terminate( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ScreenshotArgs, + $crate::mcp::AppTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let screenshot = if let Some(device) = &args.device { - $crate::XcodeCommandLineTools::new() - .devicectl() - .screenshot(device) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })? - } else { - let target = $crate::mcp::SimulatorTargetArgs { - simulator_name: args.simulator_name.clone(), - simulator_udid: args.simulator_udid.clone(), - controlkit_port: None, - }; - let simulator = Self::simulator_from_target(&target)?; - $crate::XcodeCommandLineTools::new() - .simctl() - .screenshot(&simulator.udid) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })? + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + )?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + let app_id = args.app_id.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new() + .terminate_app(&device_serial, &app_id) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + device.serial + } + $crate::mcp::SelectedTarget::AppleHost { + host, + controlkit_port, + } => { + let controlkit = + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.terminate", + ::serde_json::json!({ "bundleId": args.app_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + host.clone() + } + $crate::mcp::SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + .. + } => { + let simulator = Self::simulator_for(simulator_name, simulator_udid)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .terminate_app(&simulator.udid, &args.app_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + simulator.name + } }; - let encoded = $crate::encode_base64(screenshot); Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::image(encoded, "image/png"), + ::rmcp::model::ContentBlock::text(format!( + "Terminated {} on {}.", + args.app_id, label + )), ])) } #[::rmcp::tool( - name = $simulator_launch_app_name, - description = "Launch an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device." + name = $url_open_name, + title = "Open URL", + annotations(title = "Open URL", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: open an HTTP(S) URL or custom URL scheme on the resolved target, e.g. to deep-link into an app. When to use vs siblings: use this instead of app_launch when you need to hand the app a specific URL/deep link rather than just foreground it. Behavior: for an Apple simulator, opens through `simctl openurl`; for Android, opens through `am start -a android.intent.action.VIEW`. Prerequisites: the target must have an app installed that can handle the URL/scheme. Failure modes: errors if no target can be resolved, or if the resolved target is an Apple ControlKit host (opening URLs over ControlKit is not implemented; use a simulator or an Android target instead)." )] - async fn simulator_launch_app( + async fn url_open( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorAppArgs, + $crate::mcp::UrlOpenArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator_name, simulator_udid, host, controlkit_port) = - Self::resolve_target_fields( - &args.simulator_name, - &args.simulator_udid, - &args.host, - args.controlkit_port, - ); - if let Some(host) = &host { - let controlkit = - $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); - controlkit - .call( - "device.apps.launch", - ::serde_json::json!({ "bundleId": args.bundle_id }), - ) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - return Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Launched {} on {}.", - args.bundle_id, host - )), - ])); - } - let target = $crate::mcp::SimulatorTargetArgs { - simulator_name, - simulator_udid, - controlkit_port, + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + args.android_serial, + )?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + let url = args.url.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new().open_url(&device_serial, &url) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + device.serial + } + $crate::mcp::SelectedTarget::AppleSimulator { + simulator_name, + simulator_udid, + .. + } => { + let simulator = Self::simulator_for(simulator_name, simulator_udid)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .open_url(&simulator.udid, &args.url) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + simulator.name + } + $crate::mcp::SelectedTarget::AppleHost { host, .. } => { + return Err(::rmcp::model::ErrorData::invalid_request( + format!( + "url_open over a ControlKit host ({host}) is not supported; use a simulator or an Android target instead." + ), + None, + )); + } }; - let simulator = Self::simulator_from_target(&target)?; - $crate::XcodeCommandLineTools::new() - .simctl() - .launch_app(&simulator.udid, &args.bundle_id) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( - "Launched {} on {}.", - args.bundle_id, simulator.name + "Opened {} on {}.", + args.url, label )), ])) } #[::rmcp::tool( - name = $simulator_terminate_app_name, - description = "Terminate an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device." + name = $ui_describe_name, + title = "Describe UI", + annotations(title = "Describe UI", read_only_hint = true, idempotent_hint = true), + description = "Purpose: return the full accessibility hierarchy of the foreground app as JSON. When to use vs siblings: use this to see everything on screen before tapping or typing; prefer ui_element_list when you only need actionable elements and their tap coordinates, since it is smaller and already filtered. Behavior: calls the resolved target's ControlKit `device.dump.ui` method and returns the raw hierarchy alongside the resolved simulator (if any). Prerequisites: a reachable ControlKit endpoint. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool; ControlKit UI introspection has no Android equivalent), or if the ControlKit call fails." )] - async fn simulator_terminate_app( + async fn ui_describe( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorAppArgs, + $crate::mcp::UiTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator_name, simulator_udid, host, controlkit_port) = - Self::resolve_target_fields( - &args.simulator_name, - &args.simulator_udid, - &args.host, - args.controlkit_port, - ); - if let Some(host) = &host { - let controlkit = - $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)); - controlkit - .call( - "device.apps.terminate", - ::serde_json::json!({ "bundleId": args.bundle_id }), - ) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - return Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Terminated {} on {}.", - args.bundle_id, host - )), - ])); - } - let target = $crate::mcp::SimulatorTargetArgs { - simulator_name, - simulator_udid, - controlkit_port, - }; - let simulator = Self::simulator_from_target(&target)?; - $crate::XcodeCommandLineTools::new() - .simctl() - .terminate_app(&simulator.udid, &args.bundle_id) + let target = Self::dispatch_apple_target( + $ui_describe_name, + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + )?; + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + let result = controlkit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await .map_err(|error| { ::rmcp::model::ErrorData::internal_error(error.to_string(), None) })?; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Terminated {} on {}.", - args.bundle_id, simulator.name - )), + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "ui": result, + }))?, ])) } #[::rmcp::tool( - name = $simulator_open_url_name, - description = "Open a URL or custom URL scheme on the active simulator target (simulator only)." + name = $ui_element_list_name, + title = "List UI elements", + annotations(title = "List UI elements", read_only_hint = true, idempotent_hint = true), + description = "Purpose: list just the actionable accessibility elements of the foreground app with their labels and tap coordinates. When to use vs siblings: use this to decide where to tap; use ui_describe when you need the full hierarchy instead of a filtered, flatter list. Behavior: calls the resolved target's ControlKit `device.dump.ui` method, then filters to elements that have both a visible rect and an identifying label/name/value/rawIdentifier. Prerequisites: a reachable ControlKit endpoint. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool), or if the ControlKit call fails." )] - async fn simulator_open_url( + async fn ui_element_list( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorOpenUrlArgs, + $crate::mcp::UiTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let target = $crate::mcp::SimulatorTargetArgs { - simulator_name: args.simulator_name, - simulator_udid: args.simulator_udid, - controlkit_port: args.controlkit_port, - }; - let simulator = Self::simulator_from_target(&target)?; - $crate::XcodeCommandLineTools::new() - .simctl() - .open_url(&simulator.udid, &args.url) + let target = Self::dispatch_apple_target( + $ui_element_list_name, + args.simulator_name, + args.simulator_udid, + args.host, + args.controlkit_port, + )?; + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + let ui = controlkit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await .map_err(|error| { ::rmcp::model::ErrorData::internal_error(error.to_string(), None) })?; + let elements = $crate::extract_controlkit_elements(&ui); Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Opened {} on {}.", - args.url, simulator.name - )), + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "elements": elements, + }))?, ])) } #[::rmcp::tool( - name = $simulator_ui_dump_name, - description = "Return the full accessibility hierarchy of the foreground app as JSON. Use this first to see what is on screen before tapping or typing. Works on a simulator or a physical/remote device. Prefer list-elements when you only need actionable elements and their tap coordinates." + name = $input_tap_name, + title = "Tap (iOS/tvOS/Android)", + annotations(title = "Tap (iOS/tvOS/Android)", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: tap the resolved target at screen coordinates. When to use vs siblings: use input_click for macOS and input_spatial_tap for visionOS instead; read coordinates from ui_element_list or ui_describe first. Behavior: for an Apple target, calls ControlKit `device.io.tap`; for Android, runs `adb shell input tap`, rounding x/y to the nearest pixel. Prerequisites: a reachable ControlKit endpoint (Apple) or adb connection (Android). Failure modes: errors if no target can be resolved, if x or y is not a finite number, or if the underlying call fails." )] - async fn simulator_ui_dump( + async fn input_tap( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorControlKitArgs, + $crate::mcp::TapArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, + args.android_serial, )?; - let result = controlkit - .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let x = $crate::mcp::to_android_coordinate(args.x).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let y = $crate::mcp::to_android_coordinate(args.y).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let device_serial = device.serial.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new().tap(&device_serial, x, y) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + device.serial + } + _ => { + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + controlkit + .call( + "device.io.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Self::target_label(&target, &simulator) + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&::serde_json::json!({ - "simulator": simulator, - "ui": result, - }))?, + ::rmcp::model::ContentBlock::text(format!( + "Tapped ({}, {}) on {}.", + args.x, args.y, label + )), ])) } #[::rmcp::tool( - name = $simulator_list_elements_name, - description = "List just the actionable accessibility elements of the foreground app with their labels and tap coordinates. Use this to decide where to tap. Works on a simulator or a physical/remote device. Use describe-ui when you need the full hierarchy." + name = $input_text_name, + title = "Type text", + annotations(title = "Type text", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: type single-line text into the focused field of the resolved target. When to use vs siblings: tap the field first with input_tap to focus it, then use this to enter text. Behavior: for an Apple target, calls ControlKit `device.io.text`; for Android, runs `adb shell input text`, encoding spaces and rejecting the literal sequence `%s` (which adb's input command cannot express safely). Prerequisites: a focused text field on the target. Failure modes: errors if no target can be resolved, if text is empty or multi-line, or (Android) if text contains the literal sequence `%s`." )] - async fn simulator_list_elements( + async fn input_text( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorControlKitArgs, + $crate::mcp::TextArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, + args.android_serial, )?; - let ui = controlkit - .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; - let elements = $crate::extract_controlkit_elements(&ui); + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + let text = args.text.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new() + .type_text(&device_serial, &text) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + })?; + device.serial + } + _ => { + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + controlkit + .call("device.io.text", ::serde_json::json!({ "text": args.text })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Self::target_label(&target, &simulator) + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::json(&::serde_json::json!({ - "simulator": simulator, - "elements": elements, - }))?, + ::rmcp::model::ContentBlock::text(format!("Typed text on {}.", label)), ])) } #[::rmcp::tool( - name = $simulator_tap_name, - description = "Tap the running app at screen coordinates. Use this for iOS, tvOS, and watchOS targets, on a simulator or a physical/remote device. Use the click tool for macOS and the gesture tool for visionOS. Read coordinates from list-elements or describe-ui first." + name = $input_swipe_name, + title = "Swipe", + annotations(title = "Swipe", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: swipe between two screen coordinates on the resolved target, e.g. to scroll. When to use vs siblings: use input_tap for a single point of contact instead. Behavior: for an Apple target, calls ControlKit `device.io.swipe`; for Android, runs `adb shell input swipe` with duration_ms (default 300, rounding x/y to the nearest pixel). Prerequisites: a reachable ControlKit endpoint (Apple) or adb connection (Android). Failure modes: errors if no target can be resolved, if any coordinate is not a finite number, or (Android) if duration_ms exceeds 10000." )] - async fn simulator_tap( + async fn input_swipe( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTapArgs, + $crate::mcp::SwipeArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, + args.android_serial, )?; - controlkit - .call( - "device.io.tap", - ::serde_json::json!({ "x": args.x, "y": args.y }), - ) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let device = Self::android_device_for(serial.clone()).await?; + let x1 = $crate::mcp::to_android_coordinate(args.x1).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let y1 = $crate::mcp::to_android_coordinate(args.y1).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let x2 = $crate::mcp::to_android_coordinate(args.x2).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let y2 = $crate::mcp::to_android_coordinate(args.y2).map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error, None) + })?; + let device_serial = device.serial.clone(); + let duration_ms = args.duration_ms.unwrap_or(300); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new() + .swipe(&device_serial, x1, y1, x2, y2, duration_ms) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + })?; + device.serial + } + _ => { + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + controlkit + .call( + "device.io.swipe", + ::serde_json::json!({ + "x1": args.x1, + "y1": args.y1, + "x2": args.x2, + "y2": args.y2, + }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Self::target_label(&target, &simulator) + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ - ::rmcp::model::ContentBlock::text(format!( - "Tapped ({}, {}) on {}.", - args.x, args.y, Self::target_label(&simulator, &args.host) - )), + ::rmcp::model::ContentBlock::text(format!("Swiped on {}.", label)), ])) } #[::rmcp::tool( - name = $simulator_text_name, - description = "Type text into the focused field of the running app. Works on a simulator or a physical/remote device. Tap the field first to focus it." + name = $input_button_name, + title = "Press button", + annotations(title = "Press button", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: press a hardware or remote button on the resolved target. When to use vs siblings: use this instead of input_tap for physical/remote controls such as Home or a tvOS remote's directional pad. Behavior: for an Apple target, validates button against up/down/left/right/select/menu/home/playPause and calls ControlKit `device.io.button`; for Android, validates button against home/back/enter/recents and runs the matching `adb shell input keyevent`. Prerequisites: a reachable ControlKit endpoint (Apple) or adb connection (Android). Failure modes: errors if no target can be resolved, or if button is not in the vocabulary for the resolved target's platform (the two platforms use different button names)." )] - async fn simulator_text( + async fn input_button( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTextArgs, + $crate::mcp::ButtonArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_target( + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, + args.android_serial, )?; - controlkit - .call("device.io.text", ::serde_json::json!({ "text": args.text })) - .await - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; + let label = match &target { + $crate::mcp::SelectedTarget::Android { serial } => { + let button = $crate::mcp::AndroidButton::parse(args.button.as_str()).ok_or_else(|| { + ::rmcp::model::ErrorData::invalid_request( + format!( + "button must be one of {} for an Android target", + $crate::mcp::AndroidButton::ALL.join(", ") + ), + None, + ) + })?; + let device = Self::android_device_for(serial.clone()).await?; + let device_serial = device.serial.clone(); + Self::run_android_blocking(move || { + $crate::AndroidDebugBridge::new() + .press_button(&device_serial, button.as_str()) + }) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + })?; + device.serial + } + _ => { + let button = $crate::mcp::AppleButton::parse(args.button.as_str()).ok_or_else(|| { + ::rmcp::model::ErrorData::invalid_request( + format!( + "button must be one of {} for an Apple target", + $crate::mcp::AppleButton::ALL.join(", ") + ), + None, + ) + })?; + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; + controlkit + .call( + "device.io.button", + ::serde_json::json!({ "button": button.as_str() }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Self::target_label(&target, &simulator) + } + }; Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( - "Typed text on {}.", - Self::target_label(&simulator, &args.host) + "Pressed {} on {}.", + args.button.as_str(), label )), ])) } #[::rmcp::tool( - name = $simulator_swipe_name, - description = "Swipe between two screen coordinates in the running app, e.g. to scroll. Works on a simulator or a physical/remote device." + name = $input_click_name, + title = "Click (macOS)", + annotations(title = "Click (macOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: click the running macOS app at screen coordinates. When to use vs siblings: use input_tap for iOS/tvOS/watchOS and input_spatial_tap for visionOS instead. Behavior: calls the resolved target's ControlKit `device.io.click` method. Prerequisites: a reachable ControlKit endpoint on a macOS target. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool)." )] - async fn simulator_swipe( + async fn input_click( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorSwipeArgs, + $crate::mcp::CoordinateArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_apple_target( + $input_click_name, + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, )?; + let (_, controlkit) = Self::controlkit_for_target(&target)?; controlkit .call( - "device.io.swipe", - ::serde_json::json!({ - "x1": args.x1, - "y1": args.y1, - "x2": args.x2, - "y2": args.y2, - }), + "device.io.click", + ::serde_json::json!({ "x": args.x, "y": args.y }), ) .await .map_err(|error| { @@ -1089,53 +1701,41 @@ macro_rules! xcrs_mcp_tools { })?; Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( - "Swiped on {}.", - Self::target_label(&simulator, &args.host) + "Clicked ({}, {}).", + args.x, args.y )), ])) } #[::rmcp::tool( - name = $simulator_button_name, - description = "Press a hardware or remote button on the running target: home (iOS), or up, down, left, right, select, menu, playPause (tvOS remote). Works on a simulator or a physical/remote device. Use this to press Home instead of a dedicated tool." + name = $input_spatial_tap_name, + title = "Spatial tap (visionOS)", + annotations(title = "Spatial tap (visionOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false), + description = "Purpose: perform a spatial tap on the running visionOS app at screen coordinates. When to use vs siblings: use input_tap for iOS/tvOS/watchOS and input_click for macOS instead. Behavior: calls the resolved target's ControlKit `device.io.spatial.tap` method. Prerequisites: a reachable ControlKit endpoint on a visionOS target. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool)." )] - async fn simulator_button( + async fn input_spatial_tap( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorButtonArgs, + $crate::mcp::CoordinateArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_apple_target( + $input_spatial_tap_name, + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, )?; - let supported_buttons = [ - "up", - "down", - "left", - "right", - "select", - "menu", - "home", - "playPause", - ]; - if !supported_buttons.contains(&args.button.as_str()) { - return Err(::rmcp::model::ErrorData::invalid_request( - "button must be one of up, down, left, right, select, menu, home, or playPause", - None, - )); - } + let (_, controlkit) = Self::controlkit_for_target(&target)?; controlkit .call( - "device.io.button", - ::serde_json::json!({ "button": args.button }), + "device.io.spatial.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), ) .await .map_err(|error| { @@ -1143,33 +1743,37 @@ macro_rules! xcrs_mcp_tools { })?; Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( - "Pressed {} on {}.", - args.button, Self::target_label(&simulator, &args.host) + "Performed spatial tap at ({}, {}).", + args.x, args.y )), ])) } #[::rmcp::tool( - name = $simulator_orientation_get_name, - description = "Read the current screen orientation of the running target. Works on a simulator or a physical/remote device." + name = $orientation_get_name, + title = "Get orientation", + annotations(title = "Get orientation", read_only_hint = true, idempotent_hint = true), + description = "Purpose: report the resolved target's current screen orientation. When to use vs siblings: pair with orientation_set to check the result of a rotation. Behavior: calls the resolved target's ControlKit `device.io.orientation.get` method and returns its raw result alongside the resolved simulator (if any). Prerequisites: a reachable ControlKit endpoint. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool; adb has no orientation query)." )] - async fn simulator_orientation_get( + async fn orientation_get( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorControlKitArgs, + $crate::mcp::UiTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_apple_target( + $orientation_get_name, + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, )?; + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; let result = controlkit .call("device.io.orientation.get", ::serde_json::json!({})) .await @@ -1185,37 +1789,34 @@ macro_rules! xcrs_mcp_tools { } #[::rmcp::tool( - name = $simulator_orientation_set_name, - description = "Set the screen orientation of the running target to PORTRAIT or LANDSCAPE. Works on a simulator or a physical/remote device." + name = $orientation_set_name, + title = "Set orientation", + annotations(title = "Set orientation", read_only_hint = false, destructive_hint = false, idempotent_hint = true), + description = "Purpose: set the resolved target's screen orientation. When to use vs siblings: pair with orientation_get to confirm the result. Behavior: calls the resolved target's ControlKit `device.io.orientation.set` method with PORTRAIT or LANDSCAPE. Prerequisites: a reachable ControlKit endpoint. Failure modes: errors if no target can be resolved, or if the resolved target is Android (Apple-only tool; adb has no orientation control)." )] - async fn simulator_orientation_set( + async fn orientation_set( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorOrientationArgs, + $crate::mcp::OrientationSetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, controlkit) = Self::controlkit_from_target( - &args.simulator_name, - &args.simulator_udid, - &args.host, + let target = Self::dispatch_apple_target( + $orientation_set_name, + args.simulator_name, + args.simulator_udid, + args.host, args.controlkit_port, )?; - let orientation = args.orientation.to_uppercase(); - if orientation != "PORTRAIT" && orientation != "LANDSCAPE" { - return Err(::rmcp::model::ErrorData::invalid_request( - "orientation must be PORTRAIT or LANDSCAPE", - None, - )); - } + let (simulator, controlkit) = Self::controlkit_for_target(&target)?; controlkit .call( "device.io.orientation.set", - ::serde_json::json!({ "orientation": orientation }), + ::serde_json::json!({ "orientation": args.orientation.as_str() }), ) .await .map_err(|error| { @@ -1224,7 +1825,8 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Set orientation to {} on {}.", - orientation, Self::target_label(&simulator, &args.host) + args.orientation.as_str(), + Self::target_label(&target, &simulator) )), ])) } @@ -1234,25 +1836,24 @@ macro_rules! xcrs_mcp_tools { xcrs_mcp_tools!( XcrsMcpServer, - "xcrs_list_simulators", - "xcrs_find_simulator", - "xcrs_boot_and_install", - "xcrs_screenshot", - "xcrs_launch_app", - "xcrs_terminate_app", - "xcrs_open_url", - "xcrs_describe_ui", - "xcrs_list_elements", - "xcrs_tap", - "xcrs_type_text", - "xcrs_swipe", - "xcrs_button", - "xcrs_orientation_get", - "xcrs_orientation_set", - "xcrs_capabilities", - "xcrs_click", - "xcrs_gesture", - "xcrs_use_target" + "device_list", + "device_select", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] @@ -1265,7 +1866,12 @@ impl ServerHandler for XcrsMcpServer { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(implementation) .with_instructions( - "xcrs exposes Xcode command line tools and ControlKit runners for Apple platform testing.", + "xcrs exposes a canonical cross-platform automation contract (device_list, \ + device_select, device_capabilities, app_install_launch, screen_capture, \ + app_launch, app_terminate, url_open, ui_describe, ui_element_list, input_tap, \ + input_text, input_swipe, input_button, input_click, input_spatial_tap, \ + orientation_get, orientation_set) over Xcode/simctl/devicectl/ControlKit for \ + Apple platforms and adb for Android.", ) } } @@ -1281,3 +1887,545 @@ pub async fn serve() -> Result<()> { .map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The 18 canonical tool names shared by the standalone and embedded + /// automation servers. + const CANONICAL_TOOL_NAMES: [&str; 18] = [ + "device_list", + "device_select", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set", + ]; + + /// Fully-qualified tool names this crate used before the 18-tool + /// consolidation (e.g. `xcrs_list_simulators`, `xcrs_use_target`, + /// `xcrs_gesture`). None of these must reappear as a canonical name; a + /// match here means a rename regressed back to pre-consolidation naming. + const SUPERSEDED_TOOL_NAMES: [&str; 19] = [ + "xcrs_list_simulators", + "xcrs_find_simulator", + "xcrs_boot_and_install", + "xcrs_screenshot", + "xcrs_launch_app", + "xcrs_terminate_app", + "xcrs_open_url", + "xcrs_describe_ui", + "xcrs_list_elements", + "xcrs_tap", + "xcrs_type_text", + "xcrs_swipe", + "xcrs_button", + "xcrs_capabilities", + "xcrs_click", + "xcrs_gesture", + "xcrs_use_target", + "xcrs_orientation_get", + "xcrs_orientation_set", + ]; + + /// Tool names whose output is guaranteed to be either image content + /// (`screen_capture`), plain text with no structured payload, or a + /// passthrough of an arbitrary external JSON-RPC result (ControlKit's + /// `device.capabilities`/`device.dump.ui`/`device.io.orientation.get`, or + /// simulator/device metadata types owned outside this module). None of + /// these can be given a truthful, non-generic `output_schema` without + /// either lying about their shape or coupling this module's schema to + /// types it does not own, so they intentionally advertise no + /// `output_schema`. Only `device_select` returns structured content built + /// entirely from types this module owns, so it is the only tool with one. + const TOOLS_WITHOUT_OUTPUT_SCHEMA: [&str; 17] = [ + "device_list", + "device_capabilities", + "app_install_launch", + "screen_capture", + "app_launch", + "app_terminate", + "url_open", + "ui_describe", + "ui_element_list", + "input_tap", + "input_text", + "input_swipe", + "input_button", + "input_click", + "input_spatial_tap", + "orientation_get", + "orientation_set", + ]; + + #[test] + fn tool_router_exposes_exactly_the_eighteen_canonical_tools() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + + assert_eq!( + tools.len(), + CANONICAL_TOOL_NAMES.len(), + "expected exactly {} canonical tools, found {}", + CANONICAL_TOOL_NAMES.len(), + tools.len() + ); + + let mut actual_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + actual_names.sort_unstable(); + let mut expected_names = CANONICAL_TOOL_NAMES; + expected_names.sort_unstable(); + assert_eq!(actual_names, expected_names); + } + + #[test] + fn tool_router_uses_unprefixed_canonical_names_with_no_legacy_names() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + for tool in &tools { + assert!( + !tool.name.starts_with("xcrs_") && !tool.name.starts_with("smb_"), + "{} should not repeat a server prefix", + tool.name + ); + assert!( + !SUPERSEDED_TOOL_NAMES.contains(&tool.name.as_ref()), + "{} is a superseded pre-consolidation tool name", + tool.name + ); + } + } + + #[test] + fn every_tool_has_a_meaningful_concise_title() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + for tool in &tools { + let title = tool + .title + .as_deref() + .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name)); + assert!(!title.trim().is_empty(), "{} has a blank title", tool.name); + assert!( + title.len() <= 40, + "{} title should be concise (<= 40 chars), got {title:?}", + tool.name + ); + assert_ne!( + title, + tool.name.as_ref(), + "{} title should be a human-readable label, not the raw tool name", + tool.name + ); + } + } + + #[test] + fn every_tool_has_a_front_loaded_transparent_description() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + for tool in &tools { + let description = tool + .description + .as_deref() + .unwrap_or_else(|| panic!("{} is missing a description", tool.name)); + assert!( + !description.trim().is_empty(), + "{} has a blank description", + tool.name + ); + // Purpose Clarity: the purpose must be the first thing a reader sees. + assert!( + description.starts_with("Purpose:"), + "{} description should front-load its purpose", + tool.name + ); + // Usage Guidelines: siblings and when-to-use guidance must be present. + assert!( + description.contains("When to use"), + "{} description should say when to use it relative to sibling tools", + tool.name + ); + // Behavioral Transparency: behavior, prerequisites, and failure modes. + assert!( + description.contains("Behavior:"), + "{} description should document its behavior", + tool.name + ); + assert!( + description.contains("Prerequisites:"), + "{} description should document its prerequisites", + tool.name + ); + assert!( + description.contains("Failure modes:"), + "{} description should document its failure modes", + tool.name + ); + // Not a snapshot: only bound length loosely, so wording can evolve + // without brittle exact-text assertions. + assert!( + description.len() >= 150, + "{} description is too terse for behavioral transparency ({} chars)", + tool.name, + description.len() + ); + } + } + + #[test] + fn every_tool_has_internally_consistent_annotations() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + for tool in &tools { + let annotations = tool + .annotations + .as_ref() + .unwrap_or_else(|| panic!("{} is missing annotations", tool.name)); + let annotations_title = annotations + .title + .as_deref() + .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name)); + assert_eq!( + Some(annotations_title), + tool.title.as_deref(), + "{} top-level title and annotations title must agree", + tool.name + ); + // A tool cannot simultaneously claim to be read-only and to + // perform destructive updates. + assert!( + !(annotations.read_only_hint == Some(true) + && annotations.destructive_hint == Some(true)), + "{} annotations contradict: read_only_hint and destructive_hint are both true", + tool.name + ); + } + } + + #[test] + fn every_input_schema_property_has_a_description() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + for tool in &tools { + let Some(properties) = tool + .input_schema + .get("properties") + .and_then(|value| value.as_object()) + else { + continue; + }; + for (property_name, property_schema) in properties { + let description = property_schema + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + assert!( + !description.trim().is_empty(), + "{}.{} is missing a parameter description", + tool.name, + property_name + ); + } + } + } + + #[test] + fn output_schema_is_present_only_where_structured_content_is_guaranteed() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + assert_eq!( + tools.len(), + TOOLS_WITHOUT_OUTPUT_SCHEMA.len() + 1, + "update TOOLS_WITHOUT_OUTPUT_SCHEMA if the canonical tool set changes" + ); + for tool in &tools { + if tool.name.as_ref() == "device_select" { + assert!( + tool.output_schema.is_some(), + "device_select should advertise an output_schema for its fully-owned SelectedTarget payload" + ); + } else { + assert!( + TOOLS_WITHOUT_OUTPUT_SCHEMA.contains(&tool.name.as_ref()), + "{} has an output_schema but is not in the documented intentional-absence list", + tool.name + ); + assert!( + tool.output_schema.is_none(), + "{} should not advertise an output_schema (image, text-only, or externally-owned/passthrough content)", + tool.name + ); + } + } + } + + #[tokio::test] + async fn device_select_structured_content_matches_its_advertised_output_schema() { + let tools = XcrsMcpServer::xcrs_tool_router().list_all(); + let device_select_tool = tools + .iter() + .find(|tool| tool.name.as_ref() == "device_select") + .expect("device_select tool should be registered"); + let output_schema = device_select_tool + .output_schema + .as_ref() + .expect("device_select should have an output_schema"); + let required_properties: Vec<&str> = output_schema + .get("required") + .and_then(|value| value.as_array()) + .map(|values| values.iter().filter_map(|value| value.as_str()).collect()) + .unwrap_or_default(); + + let result = XcrsMcpServer::new() + .device_select(::rmcp::handler::server::wrapper::Parameters( + DeviceSelectArgs { + simulator_name: Some("iPhone 16".to_string()), + simulator_udid: None, + host: None, + controlkit_port: None, + android_serial: None, + }, + )) + .await + .expect("device_select should succeed with an unambiguous simulator target"); + + let structured_content = result + .structured_content + .expect("device_select should populate structured_content matching its output_schema"); + let structured_object = structured_content + .as_object() + .expect("structured_content should be a JSON object"); + for property in &required_properties { + assert!( + structured_object.contains_key(*property), + "structured_content is missing required property {property}" + ); + } + assert_eq!( + structured_content["selected"]["platform"], + serde_json::json!("apple_simulator") + ); + } + + fn apple_simulator(name: &str) -> SelectedTarget { + SelectedTarget::AppleSimulator { + simulator_name: Some(name.to_string()), + simulator_udid: None, + controlkit_port: None, + } + } + + #[test] + fn resolves_android_target_from_explicit_serial() { + let target = resolve_selected_target( + None, + None, + None, + None, + Some("emulator-5554".to_string()), + None, + ) + .expect("target should resolve"); + assert_eq!( + target, + SelectedTarget::Android { + serial: Some("emulator-5554".to_string()) + } + ); + } + + #[test] + fn resolves_apple_simulator_target_from_explicit_name() { + let target = + resolve_selected_target(Some("iPhone 16".to_string()), None, None, None, None, None) + .expect("target should resolve"); + assert_eq!(target, apple_simulator("iPhone 16")); + } + + #[test] + fn resolves_apple_host_target_from_explicit_host() { + let target = resolve_selected_target( + None, + None, + Some("192.0.2.1".to_string()), + Some(12005), + None, + None, + ) + .expect("target should resolve"); + assert_eq!( + target, + SelectedTarget::AppleHost { + host: "192.0.2.1".to_string(), + controlkit_port: Some(12005), + } + ); + } + + #[test] + fn falls_back_to_stored_target_when_nothing_provided() { + let stored = apple_simulator("iPad"); + let target = resolve_selected_target(None, None, None, None, None, Some(stored.clone())) + .expect("target should resolve"); + assert_eq!(target, stored); + } + + #[test] + fn explicit_fields_override_stored_target() { + let stored = apple_simulator("iPad"); + let target = resolve_selected_target( + None, + None, + None, + None, + Some("emulator-5554".to_string()), + Some(stored), + ) + .expect("target should resolve"); + assert_eq!( + target, + SelectedTarget::Android { + serial: Some("emulator-5554".to_string()) + } + ); + } + + #[test] + fn rejects_missing_target_with_no_stored_fallback() { + let error = resolve_selected_target(None, None, None, None, None, None) + .expect_err("target should not resolve"); + assert!(error.contains("No target provided")); + } + + #[test] + fn rejects_ambiguous_android_and_apple_simulator_fields() { + let error = resolve_selected_target( + Some("iPhone 16".to_string()), + None, + None, + None, + Some("emulator-5554".to_string()), + None, + ) + .expect_err("target should not resolve"); + assert!(error.contains("Ambiguous")); + } + + #[test] + fn rejects_ambiguous_android_and_apple_host_fields() { + let error = resolve_selected_target( + None, + None, + Some("192.0.2.1".to_string()), + None, + Some("emulator-5554".to_string()), + None, + ) + .expect_err("target should not resolve"); + assert!(error.contains("Ambiguous")); + } + + #[test] + fn rejects_ambiguous_simulator_and_host_fields() { + let error = resolve_selected_target( + Some("iPhone 16".to_string()), + None, + Some("192.0.2.1".to_string()), + None, + None, + None, + ) + .expect_err("target should not resolve"); + assert!(error.contains("Ambiguous")); + } + + #[test] + fn requires_apple_target_accepts_apple_simulator() { + require_apple_target("ui_describe", &apple_simulator("iPhone 16")) + .expect("apple simulator should be accepted"); + } + + #[test] + fn requires_apple_target_accepts_apple_host() { + require_apple_target( + "ui_describe", + &SelectedTarget::AppleHost { + host: "192.0.2.1".to_string(), + controlkit_port: None, + }, + ) + .expect("apple host should be accepted"); + } + + #[test] + fn requires_apple_target_rejects_android_with_tool_name_and_serial() { + let error = require_apple_target( + "ui_describe", + &SelectedTarget::Android { + serial: Some("emulator-5554".to_string()), + }, + ) + .expect_err("android target should be rejected"); + assert!(error.contains("ui_describe")); + assert!(error.contains("emulator-5554")); + } + + #[test] + fn converts_finite_coordinates_to_rounded_pixels() { + assert_eq!(to_android_coordinate(12.4).unwrap(), 12); + assert_eq!(to_android_coordinate(12.6).unwrap(), 13); + // Rust's f64::round() rounds half away from zero. + assert_eq!(to_android_coordinate(-3.5).unwrap(), -4); + } + + #[test] + fn rejects_non_finite_coordinates() { + assert!(to_android_coordinate(f64::NAN).is_err()); + assert!(to_android_coordinate(f64::INFINITY).is_err()); + } + + #[test] + fn parses_valid_apple_buttons_case_sensitively() { + assert_eq!(AppleButton::parse("home"), Some(AppleButton::Home)); + assert_eq!( + AppleButton::parse("playPause"), + Some(AppleButton::PlayPause) + ); + assert_eq!(AppleButton::parse("playpause"), None); + assert_eq!(AppleButton::parse("back"), None); + } + + #[test] + fn parses_valid_android_buttons() { + assert_eq!(AndroidButton::parse("back"), Some(AndroidButton::Back)); + assert_eq!( + AndroidButton::parse("recents"), + Some(AndroidButton::Recents) + ); + assert_eq!(AndroidButton::parse("menu"), None); + } + + #[test] + fn orientation_as_str_matches_controlkit_vocabulary() { + assert_eq!(Orientation::Portrait.as_str(), "PORTRAIT"); + assert_eq!(Orientation::Landscape.as_str(), "LANDSCAPE"); + } + + #[test] + fn android_capabilities_lists_supported_and_unsupported_actions() { + let capabilities = android_capabilities(); + assert_eq!(capabilities["screen_capture"], serde_json::json!(true)); + assert_eq!(capabilities["ui_describe"], serde_json::json!(false)); + assert_eq!( + capabilities["input_button"]["buttons"], + serde_json::json!(AndroidButton::ALL) + ); + } +} diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index a19f0b7..0ac8cea 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::env; use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; @@ -419,6 +420,385 @@ impl Devicectl<'_> { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AndroidDevice { + pub serial: String, + pub state: String, + pub product: Option, + pub model: Option, + pub device: Option, + pub transport_id: Option, +} + +impl AndroidDevice { + pub fn is_connected(&self) -> bool { + self.state == "device" + } +} + +/// The automation platform a normalized `device_list` entry belongs to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DevicePlatform { + Apple, + Android, +} + +/// The kind of device a normalized `device_list` entry represents. Apple physical +/// devices are not represented here: this crate has no reliable Apple +/// device-discovery command, so only Apple simulators are ever listed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeviceKind { + Simulator, + Emulator, + PhysicalDevice, +} + +/// A device entry normalized across Apple simulators and Android devices/emulators +/// for the cross-platform `device_list` tool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct NormalizedDevice { + pub platform: DevicePlatform, + pub kind: DeviceKind, + /// Simulator UDID (Apple) or device serial (Android). + pub identifier: String, + pub name: String, + pub state: String, + /// The tool used to reach this device: "simctl" or "adb". + pub transport: String, +} + +impl NormalizedDevice { + pub fn from_simulator(simulator: &Simulator) -> Self { + Self { + platform: DevicePlatform::Apple, + kind: DeviceKind::Simulator, + identifier: simulator.udid.clone(), + name: simulator.name.clone(), + state: simulator.state.clone(), + transport: "simctl".to_string(), + } + } + + pub fn from_android_device(device: &AndroidDevice) -> Self { + let kind = if device.serial.starts_with("emulator-") { + DeviceKind::Emulator + } else { + DeviceKind::PhysicalDevice + }; + let name = device + .model + .clone() + .or_else(|| device.device.clone()) + .or_else(|| device.product.clone()) + .unwrap_or_else(|| device.serial.clone()); + Self { + platform: DevicePlatform::Android, + kind, + identifier: device.serial.clone(), + name, + state: device.state.clone(), + transport: "adb".to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct AndroidDebugBridge { + adb_path: PathBuf, +} + +impl Default for AndroidDebugBridge { + fn default() -> Self { + Self { + adb_path: discover_adb_path(), + } + } +} + +impl AndroidDebugBridge { + pub fn new() -> Self { + Self::default() + } + + pub fn with_path(adb_path: impl Into) -> Self { + Self { + adb_path: adb_path.into(), + } + } + + pub fn list_devices(&self) -> Result> { + let output = run_command(&self.adb_path, ["devices", "-l"])?; + parse_adb_devices(&output) + } + + pub fn resolve_device(&self, serial: Option<&str>) -> Result { + let devices = self.list_devices()?; + if let Some(serial) = serial { + let device = devices + .into_iter() + .find(|device| device.serial == serial) + .ok_or_else(|| anyhow!("Android device '{serial}' was not found"))?; + if !device.is_connected() { + return Err(anyhow!( + "Android device '{}' is {}", + device.serial, + device.state + )); + } + return Ok(device); + } + + let connected_devices = devices + .into_iter() + .filter(AndroidDevice::is_connected) + .collect::>(); + match connected_devices.as_slice() { + [device] => Ok(device.clone()), + [] => Err(anyhow!( + "no authorized Android device is connected; check `adb devices -l`" + )), + _ => Err(anyhow!( + "multiple Android devices are connected; provide a device serial" + )), + } + } + + pub fn screenshot(&self, serial: &str) -> Result> { + self.run_for_device_bytes(serial, ["exec-out", "screencap", "-p"]) + } + + pub fn launch_app(&self, serial: &str, package_name: &str) -> Result<()> { + validate_android_package_name(package_name)?; + let component = [ + "android.intent.category.LEANBACK_LAUNCHER", + "android.intent.category.LAUNCHER", + ] + .into_iter() + .find_map(|category| { + let output = self + .run_shell( + serial, + &format!( + "cmd package resolve-activity --brief -a android.intent.action.MAIN -c {category} {}", + shell_escape(package_name) + ), + ) + .ok()?; + parse_resolved_android_activity(&output) + }) + .ok_or_else(|| anyhow!("no launchable activity found for Android app '{package_name}'"))?; + + let output = self.run_shell( + serial, + &format!("am start -W -n {}", shell_escape(&component)), + )?; + if output.lines().any(|line| line.starts_with("Error:")) { + return Err(anyhow!( + "failed to launch Android app '{package_name}': {}", + output.trim() + )); + } + Ok(()) + } + + pub fn terminate_app(&self, serial: &str, package_name: &str) -> Result<()> { + validate_android_package_name(package_name)?; + self.run_shell( + serial, + &format!("am force-stop {}", shell_escape(package_name)), + )?; + Ok(()) + } + + pub fn open_url(&self, serial: &str, url: &str) -> Result<()> { + if url.is_empty() || url.contains(['\0', '\n', '\r']) { + return Err(anyhow!("URL must be a non-empty single-line value")); + } + self.run_shell( + serial, + &format!( + "am start -a android.intent.action.VIEW -d {}", + shell_escape(url) + ), + )?; + Ok(()) + } + + pub fn tap(&self, serial: &str, x: i32, y: i32) -> Result<()> { + self.run_shell(serial, &format!("input tap {x} {y}"))?; + Ok(()) + } + + pub fn type_text(&self, serial: &str, text: &str) -> Result<()> { + let text = encode_adb_input_text(text)?; + self.run_shell(serial, &format!("input text {}", shell_escape(&text)))?; + Ok(()) + } + + pub fn swipe( + &self, + serial: &str, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + duration_ms: u32, + ) -> Result<()> { + if duration_ms > 10_000 { + return Err(anyhow!("swipe duration must not exceed 10000 milliseconds")); + } + self.run_shell( + serial, + &format!("input swipe {x1} {y1} {x2} {y2} {duration_ms}"), + )?; + Ok(()) + } + + pub fn press_button(&self, serial: &str, button: &str) -> Result<()> { + let keycode = match button { + "home" => 3, + "back" => 4, + "enter" => 66, + "recents" => 187, + _ => { + return Err(anyhow!( + "button must be one of home, back, enter, or recents" + )) + } + }; + self.run_shell(serial, &format!("input keyevent {keycode}"))?; + Ok(()) + } + + fn run_shell(&self, serial: &str, command: &str) -> Result { + run_command(&self.adb_path, ["-s", serial, "shell", command]) + } + + fn run_for_device_bytes(&self, serial: &str, args: I) -> Result> + where + I: IntoIterator, + S: AsRef, + { + let output = Command::new(&self.adb_path) + .arg("-s") + .arg(serial) + .args(args) + .output() + .with_context(|| format!("failed to run {}", self.adb_path.display()))?; + command_output_to_bytes(&self.adb_path, output) + } +} + +fn discover_adb_path() -> PathBuf { + let executable = if cfg!(windows) { "adb.exe" } else { "adb" }; + let mut candidates = ["ANDROID_SDK_ROOT", "ANDROID_HOME"] + .into_iter() + .filter_map(env::var_os) + .map(PathBuf::from) + .map(|path| path.join("platform-tools").join(executable)) + .collect::>(); + + if let Some(home) = env::var_os("HOME").map(PathBuf::from) { + candidates.push( + home.join("Library") + .join("Android") + .join("sdk") + .join("platform-tools") + .join(executable), + ); + candidates.push( + home.join("Android") + .join("Sdk") + .join("platform-tools") + .join(executable), + ); + } + if let Some(local_app_data) = env::var_os("LOCALAPPDATA").map(PathBuf::from) { + candidates.push( + local_app_data + .join("Android") + .join("Sdk") + .join("platform-tools") + .join(executable), + ); + } + + candidates + .into_iter() + .find(|path| path.is_file()) + .unwrap_or_else(|| PathBuf::from(executable)) +} + +fn parse_adb_devices(output: &str) -> Result> { + let mut devices = Vec::new(); + for line in output.lines().map(str::trim) { + if line.is_empty() || line.starts_with("List of devices attached") || line.starts_with('*') + { + continue; + } + + let fields = line.split_whitespace().collect::>(); + let serial = fields + .first() + .ok_or_else(|| anyhow!("adb device row did not contain a serial: {line}"))?; + let state = fields + .get(1) + .ok_or_else(|| anyhow!("adb device row did not contain a state: {line}"))?; + let (state, property_start) = if *state == "no" && fields.get(2) == Some(&"permissions") { + ("no permissions", 3) + } else { + (*state, 2) + }; + let properties = fields[property_start..] + .iter() + .filter_map(|field| field.split_once(':')) + .collect::>(); + + devices.push(AndroidDevice { + serial: (*serial).to_string(), + state: state.to_string(), + product: properties.get("product").map(|value| (*value).to_string()), + model: properties.get("model").map(|value| (*value).to_string()), + device: properties.get("device").map(|value| (*value).to_string()), + transport_id: properties + .get("transport_id") + .map(|value| (*value).to_string()), + }); + } + Ok(devices) +} + +fn validate_android_package_name(package_name: &str) -> Result<()> { + if package_name.is_empty() + || !package_name + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_')) + { + return Err(anyhow!( + "Android package name must contain only ASCII letters, digits, dots, and underscores" + )); + } + Ok(()) +} + +fn encode_adb_input_text(text: &str) -> Result { + if text.is_empty() || text.contains(['\0', '\n', '\r']) { + return Err(anyhow!("text must be a non-empty single-line value")); + } + if text.contains("%s") { + return Err(anyhow!( + "text containing the literal sequence '%s' is not supported by adb input" + )); + } + Ok(text.replace(' ', "%s")) +} + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + #[derive(Debug, Clone)] pub enum XcodeProject { Project(PathBuf), @@ -773,6 +1153,44 @@ fn command_output_to_result(program: &Path, output: Output) -> Result { )) } +fn command_output_to_bytes(program: &Path, output: Output) -> Result> { + if output.status.success() { + return Ok(output.stdout); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let message = [stderr.trim(), stdout.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + + if message.is_empty() { + return Err(anyhow!( + "{} exited with status {}", + program.display(), + output.status + )); + } + + Err(anyhow!( + "{} exited with status {}: {}", + program.display(), + output.status, + message + )) +} + +fn parse_resolved_android_activity(output: &str) -> Option { + output + .lines() + .map(str::trim) + .rev() + .find(|line| !line.is_empty() && line.contains('/') && !line.contains(char::is_whitespace)) + .map(str::to_string) +} + #[cfg(test)] mod tests { use super::*; @@ -863,4 +1281,111 @@ mod tests { Some("fd55:33ce:ad87::1") ); } + + #[test] + fn parses_adb_devices() { + let output = r#"List of devices attached +emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1 +SERIAL123 unauthorized usb:0-1 transport_id:2 +SERIAL456 no permissions (user denied) usb:0-2 transport_id:3 +"#; + + let devices = parse_adb_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 3); + assert_eq!(devices[0].serial, "emulator-5554"); + assert_eq!(devices[0].state, "device"); + assert_eq!(devices[0].model.as_deref(), Some("sdk_gphone64_arm64")); + assert_eq!(devices[1].serial, "SERIAL123"); + assert_eq!(devices[1].state, "unauthorized"); + assert_eq!(devices[2].serial, "SERIAL456"); + assert_eq!(devices[2].state, "no permissions"); + } + + #[test] + fn encodes_adb_input_spaces() { + let text = encode_adb_input_text("hello android").expect("text should encode"); + assert_eq!(text, "hello%sandroid"); + } + + #[test] + fn rejects_literal_adb_space_escape() { + let error = encode_adb_input_text("literal%svalue").expect_err("text should be rejected"); + assert!(error.to_string().contains("literal sequence")); + } + + #[test] + fn parses_resolved_android_activity() { + let output = "priority=0 preferredOrder=0 match=0x108000\n\ + ai.splitfire.KaroKowe/.MainActivity\n"; + + assert_eq!( + parse_resolved_android_activity(output).as_deref(), + Some("ai.splitfire.KaroKowe/.MainActivity") + ); + } + + #[test] + fn escapes_android_shell_arguments() { + assert_eq!(shell_escape("don't"), "'don'\\''t'"); + } + + #[test] + fn normalizes_apple_simulator_as_simulator_kind() { + let simulator = Simulator { + platform: ApplePlatform::Ios, + runtime_identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0".to_string(), + name: "Test-iOS-26".to_string(), + udid: "00000000-0000-0000-0000-000000000000".to_string(), + state: "Booted".to_string(), + is_available: true, + }; + + let normalized = NormalizedDevice::from_simulator(&simulator); + + assert_eq!(normalized.platform, DevicePlatform::Apple); + assert_eq!(normalized.kind, DeviceKind::Simulator); + assert_eq!(normalized.identifier, simulator.udid); + assert_eq!(normalized.name, "Test-iOS-26"); + assert_eq!(normalized.state, "Booted"); + assert_eq!(normalized.transport, "simctl"); + } + + #[test] + fn normalizes_android_emulator_serial_as_emulator_kind() { + let device = AndroidDevice { + serial: "emulator-5554".to_string(), + state: "device".to_string(), + product: Some("sdk_gphone64_arm64".to_string()), + model: Some("sdk_gphone64_arm64".to_string()), + device: Some("emu64a".to_string()), + transport_id: Some("1".to_string()), + }; + + let normalized = NormalizedDevice::from_android_device(&device); + + assert_eq!(normalized.platform, DevicePlatform::Android); + assert_eq!(normalized.kind, DeviceKind::Emulator); + assert_eq!(normalized.identifier, "emulator-5554"); + assert_eq!(normalized.name, "sdk_gphone64_arm64"); + assert_eq!(normalized.transport, "adb"); + } + + #[test] + fn normalizes_android_physical_serial_as_physical_device_kind() { + let device = AndroidDevice { + serial: "R58N123ABCD".to_string(), + state: "device".to_string(), + product: None, + model: None, + device: None, + transport_id: None, + }; + + let normalized = NormalizedDevice::from_android_device(&device); + + assert_eq!(normalized.kind, DeviceKind::PhysicalDevice); + // Falls back to the serial when no product/model/device property is reported. + assert_eq!(normalized.name, "R58N123ABCD"); + } } diff --git a/docs/controlkit.md b/docs/controlkit.md index 22366bc..3c04408 100644 --- a/docs/controlkit.md +++ b/docs/controlkit.md @@ -89,34 +89,36 @@ address with its Bonjour hostname (`.local`, discoverable via address (`xcrun devicectl device info details --device `, under `Tunnel IP Address`). -## Connect through `smb --mcp` +## Connect through the automation MCP profile -Start the CLI MCP server: +Start either automation server: ```sh -smb --mcp +xcrs --mcp +# or +smb --mcp --scope automation ``` The runner tools accept either `simulator_name` or `simulator_udid` for a simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a -physical device or any other remote runner. Call `smb_use_target` once to -remember the target so later tools need no target arguments. The standalone -`xcrs --mcp` server exposes the same tools with the `xcrs_` prefix. +physical device or any other remote runner. Call `device_select` once to +remember the target so later tools need no target arguments. Both commands +expose the same unprefixed tool names. | Tool | Runner | Purpose | | --- | --- | --- | -| `smb_use_target` | All platforms | Remember the active simulator or device for later tools. | -| `smb_capabilities` | All platforms | Read platform and capability information. | -| `smb_click` | macOS | Click at screen coordinates. | -| `smb_gesture` | visionOS | Perform a spatial tap. | -| `smb_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. | -| `smb_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. | -| `smb_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | -| `smb_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | -| `smb_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | -| `smb_screenshot` | Simulator or physical | Capture a PNG screenshot. Uses `simctl` for a simulator, or `devicectl device capture screenshot` when a `device` identifier is given — the device path works even if the ControlKit runner isn't running. | -| `smb_describe_ui` | All UI-test runners | Read the accessibility hierarchy. | -| `smb_list_elements` | All UI-test runners | Read only actionable elements with tap coordinates. | +| `device_select` | All platforms | Remember the active simulator or device for later tools. | +| `device_capabilities` | All platforms | Read platform and capability information. | +| `input_click` | macOS | Click at screen coordinates. | +| `input_spatial_tap` | visionOS | Perform a spatial tap. | +| `input_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. | +| `input_text` | iOS / tvOS, simulator or physical | Type into the focused field. | +| `input_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | +| `input_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | +| `app_launch`/`app_terminate` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | +| `screen_capture` | Simulator or physical | Capture a PNG screenshot. Uses `simctl` for a simulator, or `devicectl device capture screenshot` when a `device` identifier is given. | +| `ui_describe` | All UI-test runners | Read the accessibility hierarchy. | +| `ui_element_list` | All UI-test runners | Read only actionable elements with tap coordinates. | For a local macOS runner, omit simulator fields and pass `host` and `controlkit_port` when needed: diff --git a/docs/mcp-registry.md b/docs/mcp-registry.md index 3830a46..bf28d1a 100644 --- a/docs/mcp-registry.md +++ b/docs/mcp-registry.md @@ -2,8 +2,8 @@ The repository publishes two MCP servers to the [official MCP Registry](https://registry.modelcontextprotocol.io): -- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud resources, simulators, and ControlKit runners. -- **`io.github.smbcloudXYZ/xcrs`** — the standalone Rust `xcrs --mcp` server for Xcode and Apple simulator tooling. +- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud projects, Mail, Auth, tenants, and deployments. +- **`io.github.smbcloudXYZ/xcrs`** — **XCRS Mobile & TV Automation**, available through `xcrs --mcp` or `smb --mcp --scope automation` for Xcode, ControlKit, CoreDevice, and adb workflows. The registry is a metadata index — it doesn't host binaries. The smbCloud listing points at packages published to npm and NuGet, while the standalone diff --git a/docs/mcp.md b/docs/mcp.md index 7a9b192..bf5e106 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,19 +2,16 @@ The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI assistant or agent — Claude Desktop, Claude Code, Cursor, or any other -<<<<<<< HEAD -MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and -Auth apps directly, without you leaving the chat to run `smb` commands by hand. -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 50 -tools covering the account, project, tenant, Mail, Auth, simulator, and -ControlKit runner surfaces. -======= MCP-capable client — can send transactional email and manage your smbCloud projects, tenants, Mail apps, and Auth apps directly, without you leaving the chat to run `smb` commands by hand. Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 31 tools covering the account, project, tenant, Mail, and Auth surfaces. ->>>>>>> development + +Cross-platform mobile and TV app testing is exposed as a separate, focused MCP +profile through `xcrs --mcp` or `smb --mcp --scope automation`. Keeping cloud +and device tools in separate profiles makes tool selection more predictable for +agents. This page is the setup guide and the full tool reference. For how `--mcp` compares to the CLI's other two interfaces (headless and `--tui`), see @@ -251,50 +248,79 @@ users; it belongs to a project. | `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. | | `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** | -### Apple platform automation - -The `smb` MCP server also exposes tools for driving Apple apps under test. Most -of the action tools are **host-agnostic**: the same tool works against an Xcode -simulator *or* a physical iOS/tvOS/watchOS/visionOS device *or* the local Mac, -because they talk to an on-target [ControlKit runner](./controlkit.md). +### Mobile and TV app automation -**Recommended flow — select a target, then act:** +Start a dedicated automation server instead of mixing device tools into the +cloud profile: -1. `smb_list_simulators` (or `smb_find_simulator`) to pick a simulator, or note - a device's `host`. -2. `smb_use_target` once with the chosen `simulator_name`/`simulator_udid` (a - simulator) or `host` + optional `controlkit_port` (a device/remote runner). - The server remembers it, so later action tools need **no** target arguments. -3. `smb_describe_ui` / `smb_list_elements` to see the screen, then - `smb_tap` / `smb_type_text` / `smb_swipe` / `smb_button` and - `smb_screenshot` to drive and verify it. - -Any action tool can still override the selected target inline with its own -`simulator_name`/`simulator_udid` or `host` fields. Simulator arguments accept -either a name or a UDID; ControlKit arguments default to `127.0.0.1:12004`. +```sh +smb --mcp --scope automation +# or install the smaller standalone crate: +xcrs --mcp +``` -| Tool | Arguments | Returns | -| --- | --- | --- | -| `smb_use_target` | `simulator_name`/`simulator_udid`, or `host` + optional `controlkit_port` | The remembered active target. | -| `smb_list_simulators` | _(none)_ | All Apple simulator devices known to Xcode. | -| `smb_find_simulator` | `name` | A simulator's details (resolve a UDID). | -| `smb_capabilities` | optional target | Target platform and supported input capabilities. | -| `smb_describe_ui` | optional target | Full accessibility hierarchy of the foreground app. | -| `smb_list_elements` | optional target | Actionable accessibility elements with tap coordinates. | -| `smb_tap` | optional target, `x`, `y` | A touch tap (iOS/tvOS/watchOS). | -| `smb_click` | optional endpoint, `x`, `y` | A pointer click (macOS). | -| `smb_gesture` | optional endpoint, `x`, `y` | A spatial tap (visionOS). | -| `smb_type_text` | optional target, `text` | Text typed into the focused field. | -| `smb_swipe` | optional target, `x1`, `y1`, `x2`, `y2` | A swipe. | -| `smb_button` | optional target, `button` | A hardware/remote button press (e.g. `home`). | -| `smb_orientation_get`/`set` | optional target; `orientation` for `set` | Current or updated screen orientation. | -| `smb_launch_app`/`terminate_app` | optional target, `bundle_id` | App lifecycle confirmation (routes through the runner's `device.apps.launch`/`terminate` RPC on a device, `simctl` on a simulator). | -| `smb_open_url` | simulator target, `url` | URL-open confirmation (simulator only). | -| `smb_screenshot` | simulator target, or `device` (UDID/ECID/serial/name/DNS) | A PNG image (`simctl` for a simulator, `devicectl` for a physical device). | -| `smb_boot_and_install` | simulator target, `app_path`, `bundle_id` | Boots a simulator, installs and launches an app (simulator only). | - -The standalone `xcrs --mcp` server exposes the same surface with the `xcrs_` -prefix instead of `smb_`. +Both commands expose the same target-aware tools with the same names. Tool +names do not repeat the server brand because MCP clients already namespace +them by server. + +**Recommended flow:** + +1. Call `device_list` to discover Apple simulators and adb-connected Android + phones, tablets, emulators, and TV devices. +2. Call `device_select` with the Apple simulator/runner details or Android adb + serial. Later actions use that target by default. +3. Call `device_capabilities` before choosing an input or inspection action. +4. Use `screen_capture` or the Apple-only `ui_describe`/`ui_element_list` to + inspect the app, then drive it with the `input_*` tools. + +| Tool | Purpose | +| --- | --- | +| `device_list` | Discover available Apple simulators and Android adb devices. | +| `device_select` | Remember the Apple or Android target used by later actions. | +| `device_capabilities` | Report the selected target's supported automation operations. | +| `app_install_launch` | Boot, install, and launch an iOS simulator app bundle. | +| `app_launch` / `app_terminate` | Start or stop an installed app on the selected target. | +| `screen_capture` | Capture the selected target as a PNG image. | +| `url_open` | Open an HTTP(S) URL or custom scheme where the target supports it. | +| `ui_describe` / `ui_element_list` | Inspect the Apple ControlKit accessibility hierarchy. | +| `input_tap` / `input_text` / `input_swipe` / `input_button` | Drive shared touch, text, gesture, and button actions. | +| `input_click` | Click a macOS target with pointer coordinates. | +| `input_spatial_tap` | Perform a visionOS spatial tap. | +| `orientation_get` / `orientation_set` | Read or update orientation where supported. | + +See [ControlKit runners](./controlkit.md) for Apple runner setup. Android tools +require adb and an authorized device; Android TV app launches resolve the +device's Leanback launcher activity. + +#### Automation migration in 0.5.0 + +The automation profile is a clean breaking replacement. Old Apple-default and +Android-prefixed names are not exposed as aliases because duplicate tools make +agent selection less reliable. + +| Previous tools | Replacement | +| --- | --- | +| `smb_list_simulators`, `smb_android_device_list` | `device_list` | +| `smb_use_target` | `device_select` | +| `smb_capabilities` | `device_capabilities` | +| `smb_boot_and_install` | `app_install_launch` | +| `smb_launch_app`, `smb_android_app_launch` | `app_launch` | +| `smb_terminate_app`, `smb_android_app_terminate` | `app_terminate` | +| `smb_screenshot`, `smb_android_screenshot` | `screen_capture` | +| `smb_open_url`, `smb_android_open_url` | `url_open` | +| `smb_describe_ui` | `ui_describe` | +| `smb_list_elements` | `ui_element_list` | +| `smb_tap`, `smb_android_tap` | `input_tap` | +| `smb_type_text`, `smb_android_type_text` | `input_text` | +| `smb_swipe`, `smb_android_swipe` | `input_swipe` | +| `smb_button`, `smb_android_press_button` | `input_button` | +| `smb_click` | `input_click` | +| `smb_gesture` | `input_spatial_tap` | +| `smb_orientation_get` | `orientation_get` | +| `smb_orientation_set` | `orientation_set` | + +The standalone server makes the same replacement without the former `xcrs_` +prefix. ## Safety: tools run without confirmation diff --git a/server-xcrs.json b/server-xcrs.json index 63d6f86..6b43050 100644 --- a/server-xcrs.json +++ b/server-xcrs.json @@ -1,8 +1,8 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.smbcloudXYZ/xcrs", - "title": "XCRS Xcode Simulator Tools", - "description": "Rust-native Xcode command-line and Apple simulator automation tools exposed through MCP.", + "title": "XCRS Mobile & TV Automation", + "description": "Test mobile and TV apps through MCP on iOS simulators, physical Apple devices, Android phones, and Android TV using Xcode, ControlKit, and adb.", "version": "0.4.13", "websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs", "repository": { @@ -24,7 +24,7 @@ { "type": "named", "name": "--mcp", - "description": "Run the XCRS server as an MCP server over stdio.", + "description": "Run XCRS Mobile & TV Automation as an MCP server over stdio.", "isRequired": true } ] @@ -34,17 +34,19 @@ "io.modelcontextprotocol.registry/publisher-provided": { "categories": [ "developer-tools", - "xcode", - "ios", - "simulator" + "testing", + "automation" ], "keywords": [ + "mobile app testing", + "TV app testing", + "Android TV automation", + "Apple TV automation", + "adb MCP", + "iOS simulator automation", + "physical device testing", + "mobile UI automation", "Xcode", - "iOS simulator", - "tvOS", - "visionOS", - "watchOS", - "macOS", "ControlKit" ] }