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 2df20e9..d816afd 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -1,26 +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_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" + 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 0869087..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,225 +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 DeviceScreenshotArgs { - /// Physical device identifier known to CoreDevice: UDID, ECID, serial - /// number, user-provided name, or DNS name. - pub device: String, +/// 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, + }) +} + +/// Target-only arguments shared by `device_select` and `device_capabilities`. #[derive(Debug, Deserialize, JsonSchema)] -pub struct IosAppTestArgs { - /// Exact simulator name to use. +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, - /// Simulator UDID to use. + /// Apple simulator UDID to remember as the active target. Mutually exclusive + /// with `host` and `android_serial`. #[serde(default)] pub simulator_udid: Option, - /// Path to the `.app` bundle. - pub app_path: PathBuf, - /// Installed app bundle identifier. - pub bundle_id: String, - /// Terminate the app before launching it. + /// 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 terminate_before_launch: bool, + 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 SimulatorTargetArgs { - /// Exact simulator name. +pub struct DeviceCapabilitiesArgs { + /// 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 of a physical device, remote runner, or local Mac. + #[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, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AppInstallLaunchArgs { + /// Exact simulator name to use. Provide this or `simulator_udid`. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID to use. Provide this or `simulator_name`. + #[serde(default)] + pub simulator_udid: Option, + /// Path to the `.app` bundle on disk. + pub app_path: PathBuf, + /// Bundle identifier of the app inside `app_path`. + pub bundle_id: String, + /// Terminate a previously running instance of the app before launching it. + #[serde(default)] + pub terminate_before_launch: bool, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorAppArgs { - /// 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 for a physical device or remote runner. Omit for a local simulator. + /// 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, - /// App bundle identifier. - pub bundle_id: String, + /// 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 SimulatorOpenUrlArgs { - /// 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 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, - /// HTTP(S) URL or custom URL scheme. - pub url: 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 SimulatorTapArgs { - /// 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 for a physical device or remote runner. Omit for a local simulator. + /// 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, - /// 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, + /// HTTP(S) URL or custom URL scheme to open. + pub url: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorTextArgs { - /// 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, - /// Text to type into the focused field. - pub text: String, + /// 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 SimulatorSwipeArgs { - /// 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, - /// Swipe start horizontal coordinate. - pub x1: i32, - /// Swipe start vertical coordinate. - pub y1: i32, - /// Swipe end horizontal coordinate. - pub x2: i32, - /// Swipe end vertical coordinate. - pub y2: i32, + /// 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 SimulatorOrientationArgs { - /// 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, - /// Desired orientation: PORTRAIT or LANDSCAPE. - pub orientation: String, + /// Android device serial from `adb devices -l`. + #[serde(default)] + pub android_serial: Option, + /// Swipe start horizontal coordinate. + pub x1: f64, + /// Swipe start vertical coordinate. + pub y1: f64, + /// Swipe end horizontal coordinate. + pub x2: f64, + /// Swipe end vertical coordinate. + pub y2: f64, + /// Swipe duration in milliseconds. Defaults to 300. Android only; Apple + /// ControlKit swipes ignore this field. + #[serde(default)] + 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. @@ -249,329 +641,332 @@ 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_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 + $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 simulator_from_target( - target: &$crate::mcp::SimulatorTargetArgs, + 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::SelectedTarget> { + Self::selected_target_slot() + .lock() + .ok() + .and_then(|slot| slot.clone()) + } + + /// 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, + 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)) + } + + /// 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(); - match (&target.simulator_udid, &target.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( - "Provide either simulator_name or simulator_udid.", + "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, > { - if let Some(host) = host { - return Ok(( + match target { + $crate::mcp::SelectedTarget::AppleHost { + host, + controlkit_port, + } => Ok(( None, $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_name.clone(), - simulator_udid: simulator_udid.clone(), - 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(); - } - host.clone().unwrap_or_else(|| "127.0.0.1".to_string()) + 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}"))? } - fn controlkit_from_endpoint( - endpoint: &$crate::mcp::ControlKitEndpointArgs, - ) -> ::std::result::Result< - ($crate::ControlKit, Option<$crate::Simulator>), - ::rmcp::model::ErrorData, - > { - let simulator = match (&endpoint.simulator_udid, &endpoint.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 = endpoint.host.as_deref().unwrap_or("127.0.0.1"); - Ok(( - $crate::ControlKit::with_host( - host, - endpoint.controlkit_port.unwrap_or(12004), - ), - simulator, - )) + 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 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, + 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()), } } #[::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." + 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 controlkit_capabilities( + async fn device_list( &self, - ::rmcp::handler::server::wrapper::Parameters( - args, - ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitEndpointArgs, - >, ) -> ::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 mut devices = Vec::new(); + let mut errors = ::serde_json::Map::new(); - #[::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." - )] - async fn macos_click( - &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.click", - ::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!( - "Clicked ({}, {}).", - args.x, args.y - )), - ])) - } + match $crate::XcodeCommandLineTools::new().simctl().list_simulators() { + Ok(simulators) => devices.extend( + simulators + .iter() + .map($crate::NormalizedDevice::from_simulator), + ), + Err(error) => { + errors.insert("apple".to_string(), ::serde_json::json!(error.to_string())); + } + } + + 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())); + } + } - #[::rmcp::tool( - name = $visionos_spatial_tap_name, - description = "Perform a spatial tap on a visionOS ControlKit runner." - )] - async fn visionos_spatial_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.spatial.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!( - "Performed spatial tap at ({}, {}).", - args.x, args.y - )), + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "devices": devices, + "errors": errors, + }))?, ])) } #[::rmcp::tool( - name = $watchos_tap_name, - description = "Tap a watchOS ControlKit runner at screen coordinates." + 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 watchos_tap( + async fn device_select( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::ControlKitCoordinateArgs, + $crate::mcp::DeviceSelectArgs, >, ) -> ::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." - )] - async fn simulator_list( - &self, - ) -> ::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)?, - ])) + 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(target.clone()), + Err(_) => { + return Err(::rmcp::model::ErrorData::internal_error( + "Failed to store the selected target.", + None, + )) + } + } + 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 = $simulator_find_name, - description = "Find an Apple simulator by its exact name and return its details as JSON." + 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 simulator_find( + async fn device_capabilities( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorFindArgs, + $crate::mcp::DeviceCapabilitiesArgs, >, ) -> ::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)?, - ])) + 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 = $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." + 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 ios_app_test( + async fn app_install_launch( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::IosAppTestArgs, + $crate::mcp::AppInstallLaunchArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, @@ -601,224 +996,348 @@ macro_rules! xcrs_mcp_tools { } #[::rmcp::tool( - name = $simulator_screenshot_name, - description = "Capture a PNG screenshot from an Apple simulator." + 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 simulator_screenshot( + async fn screen_capture( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTargetArgs, + $crate::mcp::ScreenCaptureArgs, >, ) -> ::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"), - ])) - } + 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", + ), + ])); + } - #[::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 encoded = $crate::encode_base64(screenshot); + 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::image(encoded, "image/png"), + ::rmcp::model::ContentBlock::image( + $crate::encode_base64(screenshot), + "image/png", + ), ])) } #[::rmcp::tool( - name = $simulator_launch_app_name, - description = "Launch an installed app on an Apple simulator by bundle identifier." + 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_launch_app( + async fn app_launch( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorAppArgs, + $crate::mcp::AppTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - if let Some(host) = &args.host { - let controlkit = - $crate::ControlKit::with_host(host, args.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: args.simulator_name, - simulator_udid: args.simulator_udid, - controlkit_port: args.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 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 + } }; - 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 + args.app_id, label )), ])) } #[::rmcp::tool( - name = $simulator_terminate_app_name, - description = "Terminate an installed app on an Apple simulator by bundle identifier." + 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_terminate_app( + async fn app_terminate( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorAppArgs, + $crate::mcp::AppTargetArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - if let Some(host) = &args.host { - let controlkit = - $crate::ControlKit::with_host(host, args.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: args.simulator_name, - simulator_udid: args.simulator_udid, - controlkit_port: args.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 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 simulator = Self::simulator_from_target(&target)?; - $crate::XcodeCommandLineTools::new() - .simctl() - .terminate_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!( "Terminated {} on {}.", - args.bundle_id, simulator.name + args.app_id, label )), ])) } #[::rmcp::tool( - name = $simulator_open_url_name, - description = "Open a URL or custom URL scheme on an Apple simulator." + 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_open_url( + async fn url_open( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorOpenUrlArgs, + $crate::mcp::UrlOpenArgs, >, ) -> ::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 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() - .open_url(&simulator.udid, &args.url) - .map_err(|error| { - ::rmcp::model::ErrorData::internal_error(error.to_string(), None) - })?; Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Opened {} on {}.", - args.url, simulator.name + args.url, label )), ])) } #[::rmcp::tool( - name = $simulator_ui_dump_name, - description = "Return the accessibility UI hierarchy from the foreground Apple app through ControlKit." + 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_ui_dump( + async fn ui_describe( &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( + $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 @@ -834,26 +1353,30 @@ 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." + 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_list_elements( + async fn ui_element_list( &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( + $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 @@ -870,197 +1393,349 @@ macro_rules! xcrs_mcp_tools { } #[::rmcp::tool( - name = $simulator_tap_name, - description = "Tap an Apple simulator screen at the given coordinates through ControlKit." + 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_tap( + async fn input_tap( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTapArgs, + $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, )?; - 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 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::text(format!( "Tapped ({}, {}) on {}.", - args.x, args.y, Self::target_label(&simulator, &args.host) + args.x, args.y, label )), ])) } #[::rmcp::tool( - name = $simulator_text_name, - description = "Type text into the focused Apple simulator field through ControlKit." + 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_text( + async fn input_text( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorTextArgs, + $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, )?; - 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 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::text(format!( - "Typed text on {}.", - Self::target_label(&simulator, &args.host) - )), + ::rmcp::model::ContentBlock::text(format!("Typed text on {}.", label)), ])) } #[::rmcp::tool( - name = $simulator_swipe_name, - description = "Swipe between two screen coordinates on an Apple simulator through ControlKit." + 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_swipe( + async fn input_swipe( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorSwipeArgs, + $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.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) - })?; + 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!("Swiped on {}.", label)), + ])) + } + + #[::rmcp::tool( + 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 input_button( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ButtonArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + 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 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!( - "Swiped on {}.", - Self::target_label(&simulator, &args.host) + "Pressed {} on {}.", + args.button.as_str(), label )), ])) } #[::rmcp::tool( - name = $simulator_home_name, - description = "Press the iOS Home button through ControlKit." + 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_home( + async fn input_click( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorControlKitArgs, + $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.button", ::serde_json::json!({ "button": "home" })) + .call( + "device.io.click", + ::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!( - "Pressed Home on {}.", - Self::target_label(&simulator, &args.host) + "Clicked ({}, {}).", + args.x, args.y )), ])) } #[::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." + 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| { @@ -1068,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 iOS simulator orientation through ControlKit." + 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 @@ -1110,37 +1789,34 @@ macro_rules! xcrs_mcp_tools { } #[::rmcp::tool( - name = $simulator_orientation_set_name, - description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through ControlKit." + 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| { @@ -1149,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) )), ])) } @@ -1159,27 +1836,24 @@ 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" + "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())] @@ -1192,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.", ) } } @@ -1208,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 08d1fd7..3c04408 100644 --- a/docs/controlkit.md +++ b/docs/controlkit.md @@ -89,33 +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. 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_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. | +| `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 c653da7..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,49 +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.** | -### 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). +### Mobile and TV app automation -| 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_`. +Start a dedicated automation server instead of mixing device tools into the +cloud profile: + +```sh +smb --mcp --scope automation +# or install the smaller standalone crate: +xcrs --mcp +``` + +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" ] }