Add cross-platform mobile and TV MCP automation
Unify Apple and Android device discovery, target selection, capabilities, and app control behind a focused 18-tool automation profile. Separate smbCloud tools from XCRS automation and update TDQS-oriented definitions, tests, registry metadata, and documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Seto Elkahfi committed
Jul 31, 2026 at 16:47 UTC
8d87975ea1101bf1237b4ac54b97a89adf72f680
14 files changed
+2783
-808
README.MD
+11
@@ -119,6 +119,17 @@ Auth comes from `smb login`, so a terminal session and an agent session share th
119
token and the same selected project. See [`docs/mcp.md`](./docs/mcp.md) for the full tool
120
reference.
121
122
+For mobile and TV app testing, run the focused **XCRS Mobile & TV
123
+Automation** profile:
124
+
125
+```sh
126
+claude mcp add xcrs -- smb --mcp --scope automation
127
+```
128
+
129
+It exposes target-aware Xcode, ControlKit, CoreDevice, and adb tools without
130
+mixing them into the cloud tool set. The same profile is also available from
131
+the standalone `xcrs --mcp` crate.
132
+
133
## CI / non-interactive mode
134
135
Pass `--ci` (or set `SMB_CI=1`, or run under any provider that sets `CI`) to
crates/cli/src/cli/mod.rs
+56
-1
@@ -1,6 +1,6 @@
1
use {
2
crate::{account, cloud_auth, mail, project, tenant},
3
- clap::{Parser, Subcommand},
3
+ clap::{Parser, Subcommand, ValueEnum},
4
smbcloud_network::environment::Environment,
5
spinners::Spinner,
6
std::path::PathBuf,
@@ -18,6 +18,13 @@ impl CommandResult {
18
}
19
}
20
21
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
22
+pub enum McpScope {
23
+ #[default]
24
+ Cloud,
25
+ Automation,
26
+}
27
+
28
#[derive(Parser)]
29
#[clap(author, version, about)]
30
pub struct Cli {
@@ -45,6 +52,17 @@ pub struct Cli {
52
#[arg(long, global = true)]
53
pub mcp: bool,
54
55
+ /// MCP tool profile to expose. Cloud serves smbCloud resources; automation
56
+ /// serves cross-platform mobile and TV device tools.
57
+ #[arg(
58
+ long = "scope",
59
+ global = true,
60
+ value_enum,
61
+ default_value_t,
62
+ requires = "mcp"
63
+ )]
64
+ pub mcp_scope: McpScope,
65
+
66
#[command(subcommand)]
67
pub command: Option<Commands>,
68
}
@@ -109,6 +127,43 @@ pub enum Commands {
127
Migrate {},
128
}
129
130
+#[cfg(test)]
131
+mod tests {
132
+ use super::*;
133
+
134
+ #[test]
135
+ fn mcp_defaults_to_cloud_scope() {
136
+ let cli = Cli::try_parse_from(["smb", "--mcp"]).expect("MCP arguments should parse");
137
+
138
+ assert_eq!(cli.mcp_scope, McpScope::Cloud);
139
+ }
140
+
141
+ #[test]
142
+ fn mcp_accepts_automation_scope() {
143
+ let cli = Cli::try_parse_from(["smb", "--mcp", "--scope", "automation"])
144
+ .expect("automation MCP arguments should parse");
145
+
146
+ assert_eq!(cli.mcp_scope, McpScope::Automation);
147
+ }
148
+
149
+ #[test]
150
+ fn scope_requires_mcp_mode() {
151
+ // `Cli` has no `Debug` impl (clap's `Parser` doesn't require one), so
152
+ // `expect_err`/`unwrap_err` (which bound the `Ok` type on `Debug`)
153
+ // don't apply here; match the `Result` instead.
154
+ let result = Cli::try_parse_from(["smb", "--scope", "automation"]);
155
+ let error = match result {
156
+ Err(error) => error,
157
+ Ok(_) => panic!("scope without MCP mode should be rejected"),
158
+ };
159
+
160
+ assert_eq!(
161
+ error.kind(),
162
+ clap::error::ErrorKind::MissingRequiredArgument
163
+ );
164
+ }
165
+}
166
+
167
#[derive(Subcommand)]
168
pub enum ControlKitCommands {
169
#[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")]
crates/cli/src/main.rs
+1
-1
@@ -151,7 +151,7 @@ async fn run_mcp(cli: Cli) -> Result<()> {
151
} else {
152
setup_logging(cli.environment, None)?;
153
}
154
- smbcloud_cli::mcp::serve(cli.environment).await
154
+ smbcloud_cli::mcp::serve(cli.environment, cli.mcp_scope).await
155
}
156
157
async fn run(cli: Cli) -> Result<CommandResult> {
crates/cli/src/mcp.rs
+70
-8
@@ -1,8 +1,9 @@
1
//! MCP (Model Context Protocol) server interface.
2
//!
3
//! When `smb` is started with `--mcp`, it runs as an MCP server over stdio
4
-//! instead of executing a one-shot command. The server exposes smbCloud
5
-//! operations as MCP tools built on the official `rmcp` SDK.
4
+//! instead of executing a one-shot command. The default profile exposes
5
+//! smbCloud operations; `--scope automation` exposes the shared XCRS mobile
6
+//! and TV automation profile.
7
//!
8
//! Tools call the same library functions the CLI handlers use, but return
9
//! structured JSON rather than rendering spinners or a TUI — the stdout stream
@@ -17,6 +18,7 @@
18
use {
19
crate::{
20
account::lib::is_logged_in,
21
+ cli::McpScope,
22
client,
23
mail::{
24
current_project::{resolve_optional_project_id, resolve_required_project_id},
@@ -978,9 +980,7 @@ impl SmbMcpServer {
980
}
981
}
982
981
-#[tool_handler(
982
- router = (Self::cloud_tool_router() + Self::xcrs_tool_router())
983
-)]
983
+#[tool_handler(router = Self::cloud_tool_router())]
984
impl ServerHandler for SmbMcpServer {
985
fn get_info(&self) -> ServerInfo {
986
// `Implementation` is `#[non_exhaustive]`, so start from the build-env
@@ -995,14 +995,18 @@ impl ServerHandler for SmbMcpServer {
995
"smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
996
`smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
997
the tenant/project context other tools default to, shared with the CLI's own \
998
- `smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \
999
- visionOS, watchOS, and macOS runners.",
998
+ `smb tenant use` / `smb project use`. Use `smb --mcp --scope automation` \
999
+ instead for mobile and TV app automation tools.",
1000
)
1001
}
1002
}
1003
1004
/// Run the MCP server over stdio until the client disconnects.
1005
-pub async fn serve(environment: Environment) -> Result<()> {
1005
+pub async fn serve(environment: Environment, scope: McpScope) -> Result<()> {
1006
+ if scope == McpScope::Automation {
1007
+ return mcp_xcrs::serve().await;
1008
+ }
1009
+
1010
let running = SmbMcpServer::new(environment)
1011
.serve(stdio())
1012
.await
@@ -1013,3 +1017,61 @@ pub async fn serve(environment: Environment) -> Result<()> {
1017
.map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?;
1018
Ok(())
1019
}
1020
+
1021
+#[cfg(test)]
1022
+mod tests {
1023
+ use super::*;
1024
+
1025
+ #[test]
1026
+ fn cloud_router_exposes_known_cloud_tools() {
1027
+ let tools = SmbMcpServer::cloud_tool_router().list_all();
1028
+ let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
1029
+
1030
+ for cloud_only in [
1031
+ "me",
1032
+ "project_list",
1033
+ "tenant_list",
1034
+ "mail_list",
1035
+ "auth_app_list",
1036
+ ] {
1037
+ assert!(
1038
+ names.contains(&cloud_only),
1039
+ "cloud router should expose {cloud_only:?}"
1040
+ );
1041
+ }
1042
+ }
1043
+
1044
+ #[test]
1045
+ fn cloud_router_excludes_automation_tools() {
1046
+ let tools = SmbMcpServer::cloud_tool_router().list_all();
1047
+ let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
1048
+
1049
+ const AUTOMATION_TOOL_NAMES: [&str; 18] = [
1050
+ "device_list",
1051
+ "device_select",
1052
+ "device_capabilities",
1053
+ "app_install_launch",
1054
+ "screen_capture",
1055
+ "app_launch",
1056
+ "app_terminate",
1057
+ "url_open",
1058
+ "ui_describe",
1059
+ "ui_element_list",
1060
+ "input_tap",
1061
+ "input_text",
1062
+ "input_swipe",
1063
+ "input_button",
1064
+ "input_click",
1065
+ "input_spatial_tap",
1066
+ "orientation_get",
1067
+ "orientation_set",
1068
+ ];
1069
+
1070
+ for automation_only in AUTOMATION_TOOL_NAMES {
1071
+ assert!(
1072
+ !names.contains(&automation_only),
1073
+ "cloud router should not expose automation tool {automation_only:?}"
1074
+ );
1075
+ }
1076
+ }
1077
+}
crates/cli/src/mcp_xcrs.rs
+153
-21
@@ -1,24 +1,156 @@
1
-use super::SmbMcpServer;
1
+//! Automation MCP profile: `smb --mcp --scope automation` re-exposes the
2
+//! canonical cross-platform mobile and TV automation tool set defined in the
3
+//! `xcrs` crate, under its own [`AutomationMcpServer`].
4
+//!
5
+//! This is a separate server type rather than more tools bolted onto
6
+//! [`super::SmbMcpServer`] so a single MCP session only ever sees one
7
+//! profile's tools — cloud or automation, never both merged together.
8
+
9
+use {
10
+ anyhow::{anyhow, Result},
11
+ rmcp::{
12
+ model::{Implementation, ServerCapabilities, ServerInfo},
13
+ transport::stdio,
14
+ ServerHandler, ServiceExt,
15
+ },
16
+};
17
+
18
+/// The shared XCRS mobile/TV automation MCP server. Tool names carry no
19
+/// `smb_`/`xcrs_` prefix: the MCP client already namespaces tools by server,
20
+/// so a prefix here would just duplicate that.
21
+#[derive(Debug, Default)]
22
+pub struct AutomationMcpServer;
23
+
24
+impl AutomationMcpServer {
25
+ pub fn new() -> Self {
26
+ Self
27
+ }
28
+}
29
30
xcrs::xcrs_mcp_tools!(
4
- SmbMcpServer,
5
- "smb_list_simulators",
6
- "smb_find_simulator",
7
- "smb_boot_and_install",
8
- "smb_screenshot",
9
- "smb_launch_app",
10
- "smb_terminate_app",
11
- "smb_open_url",
12
- "smb_describe_ui",
13
- "smb_list_elements",
14
- "smb_tap",
15
- "smb_type_text",
16
- "smb_swipe",
17
- "smb_button",
18
- "smb_orientation_get",
19
- "smb_orientation_set",
20
- "smb_capabilities",
21
- "smb_click",
22
- "smb_gesture",
23
- "smb_use_target"
31
+ AutomationMcpServer,
32
+ "device_list",
33
+ "device_select",
34
+ "device_capabilities",
35
+ "app_install_launch",
36
+ "screen_capture",
37
+ "app_launch",
38
+ "app_terminate",
39
+ "url_open",
40
+ "ui_describe",
41
+ "ui_element_list",
42
+ "input_tap",
43
+ "input_text",
44
+ "input_swipe",
45
+ "input_button",
46
+ "input_click",
47
+ "input_spatial_tap",
48
+ "orientation_get",
49
+ "orientation_set"
50
);
51
+
52
+#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
53
+impl ServerHandler for AutomationMcpServer {
54
+ fn get_info(&self) -> ServerInfo {
55
+ // `Implementation` is `#[non_exhaustive]`, so start from the build-env
56
+ // default and override the identity fields.
57
+ let mut server_info = Implementation::from_build_env();
58
+ server_info.name = "XCRS Mobile & TV Automation".to_string();
59
+ server_info.version = env!("CARGO_PKG_VERSION").to_string();
60
+
61
+ ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
62
+ .with_server_info(server_info)
63
+ .with_instructions(
64
+ "Cross-platform mobile and TV app automation over Apple simulators/ControlKit \
65
+ and Android adb. Start with `device_list` to discover available simulators and \
66
+ adb-connected devices, then `device_select` to remember one for later calls, \
67
+ then `device_capabilities` to check what it supports before choosing an \
68
+ inspection or input tool. Every action tool also accepts an inline target \
69
+ instead of the remembered one. Use plain `smb --mcp` instead for smbCloud \
70
+ account, project, tenant, Mail, and Auth tools.",
71
+ )
72
+ }
73
+}
74
+
75
+/// Run the automation MCP server over stdio until the client disconnects.
76
+pub async fn serve() -> Result<()> {
77
+ let running = AutomationMcpServer::new()
78
+ .serve(stdio())
79
+ .await
80
+ .map_err(|error| anyhow!("Failed to start automation MCP server: {error}"))?;
81
+ running
82
+ .waiting()
83
+ .await
84
+ .map_err(|error| anyhow!("Automation MCP server stopped unexpectedly: {error}"))?;
85
+ Ok(())
86
+}
87
+
88
+#[cfg(test)]
89
+mod tests {
90
+ use super::*;
91
+
92
+ const CANONICAL_TOOL_NAMES: [&str; 18] = [
93
+ "device_list",
94
+ "device_select",
95
+ "device_capabilities",
96
+ "app_install_launch",
97
+ "screen_capture",
98
+ "app_launch",
99
+ "app_terminate",
100
+ "url_open",
101
+ "ui_describe",
102
+ "ui_element_list",
103
+ "input_tap",
104
+ "input_text",
105
+ "input_swipe",
106
+ "input_button",
107
+ "input_click",
108
+ "input_spatial_tap",
109
+ "orientation_get",
110
+ "orientation_set",
111
+ ];
112
+
113
+ #[test]
114
+ fn automation_router_exposes_exactly_the_canonical_eighteen_tools() {
115
+ let tools = AutomationMcpServer::xcrs_tool_router().list_all();
116
+ let mut names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
117
+ names.sort_unstable();
118
+
119
+ let mut expected = CANONICAL_TOOL_NAMES;
120
+ expected.sort_unstable();
121
+
122
+ assert_eq!(names, expected, "automation router tool set changed");
123
+ }
124
+
125
+ #[test]
126
+ fn automation_tool_names_carry_no_server_prefix() {
127
+ let tools = AutomationMcpServer::xcrs_tool_router().list_all();
128
+
129
+ for tool in &tools {
130
+ assert!(
131
+ !tool.name.starts_with("smb_") && !tool.name.starts_with("xcrs_"),
132
+ "tool name {:?} should not carry a redundant server prefix",
133
+ tool.name
134
+ );
135
+ }
136
+ }
137
+
138
+ #[test]
139
+ fn automation_router_excludes_cloud_tools() {
140
+ let tools = AutomationMcpServer::xcrs_tool_router().list_all();
141
+ let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
142
+
143
+ for cloud_only in [
144
+ "me",
145
+ "project_list",
146
+ "tenant_list",
147
+ "mail_list",
148
+ "auth_app_list",
149
+ ] {
150
+ assert!(
151
+ !names.contains(&cloud_only),
152
+ "automation router should not expose cloud tool {cloud_only:?}"
153
+ );
154
+ }
155
+ }
156
+}
crates/xcrs/Cargo.toml
+2
-2
@@ -3,11 +3,11 @@ name = "xcrs"
3
version = "0.4.13"
4
edition = "2021"
5
authors = ["Seto Elkahfi <seto@smbcloud.xyz>"]
6
-description = "Rust abstraction for Xcode command line tools."
6
+description = "Cross-platform mobile and TV app automation over MCP."
7
license = "Apache-2.0"
8
repository = "https://github.com/smbcloudXYZ/smbcloud-cli"
9
documentation = "https://smbcloud.xyz/posts"
10
-keywords = ["xcode", "ios", "simulator", "cli"]
10
+keywords = ["mcp", "mobile", "android", "ios", "automation"]
11
categories = ["command-line-utilities", "development-tools"]
12
13
[lib]
crates/xcrs/README.md
+15
-3
@@ -1,7 +1,9 @@
1
-# XCRS
1
+# XCRS Mobile & TV Automation
2
3
-Rust-native Xcode command-line and Apple simulator automation tools with an
4
-MCP server for simulator and ControlKit workflows.
3
+Cross-platform MCP tools for testing mobile and TV apps on Apple simulators,
4
+physical Apple devices, Android phones, and Android TV devices. XCRS combines
5
+Xcode, ControlKit, CoreDevice, and adb behind one target-aware automation
6
+workflow.
7
8
MCP Registry name: `mcp-name: io.github.smbcloudXYZ/xcrs`
9
@@ -12,6 +14,16 @@ cargo install xcrs
14
xcrs --mcp
15
```
16
17
+Android tools use `adb` from `ANDROID_SDK_ROOT`, `ANDROID_HOME`, the standard
18
+Android SDK locations, or `PATH`. Connect a device with USB debugging enabled
19
+and authorize the host before selecting it with `device_select`.
20
+
21
+The same automation profile is available from the full smbCloud CLI:
22
+
23
+```sh
24
+smb --mcp --scope automation
25
+```
26
+
27
## Repository
28
29
Source and documentation are available in the
crates/xcrs/src/main.rs
+1
-1
@@ -5,7 +5,7 @@ use xcrs::{IosAppTest, XcodeCommandLineTools};
5
6
#[derive(Debug, Parser)]
7
#[command(name = "xcrs")]
8
-#[command(about = "Rust abstraction over Xcode command line tools")]
8
+#[command(about = "Cross-platform mobile and TV app automation")]
9
struct Cli {
10
/// Run as a standalone Model Context Protocol server over stdio.
11
#[arg(long)]
crates/xcrs/src/mcp.rs
+1839
-691
@@ -1,3 +1,19 @@
1
+//! Cross-platform automation MCP contract.
2
+//!
3
+//! This module defines the canonical tool set exposed by `xcrs` (and re-exposed by
4
+//! embedders such as `smb --mcp --scope automation`): `device_list`, `device_select`,
5
+//! `device_capabilities`, `app_install_launch`, `screen_capture`, `app_launch`,
6
+//! `app_terminate`, `url_open`, `ui_describe`, `ui_element_list`, `input_tap`,
7
+//! `input_text`, `input_swipe`, `input_button`, `input_click`, `input_spatial_tap`,
8
+//! `orientation_get`, and `orientation_set`. Each name is supplied by the embedder
9
+//! through [`xcrs_mcp_tools`] so the standalone crate and CLI profile share one
10
+//! implementation and one public contract.
11
+//!
12
+//! Targets are Apple simulators, Apple ControlKit hosts (physical/remote devices or
13
+//! a local macOS runner), or Android devices reachable through adb. A target can be
14
+//! selected once with `device_select` and reused by later calls, or passed explicitly
15
+//! on any call. [`resolve_selected_target`] is the single, unit-tested place that
16
+//! validates a target is unambiguous.
17
use {
18
anyhow::{anyhow, Result},
19
rmcp::{
@@ -6,264 +22,601 @@ use {
22
ServerHandler, ServiceExt,
23
},
24
schemars::JsonSchema,
9
- serde::Deserialize,
25
+ serde::{Deserialize, Serialize},
26
std::path::PathBuf,
27
};
28
13
-#[derive(Debug, Deserialize, JsonSchema)]
14
-pub struct SimulatorFindArgs {
15
- /// Exact simulator name to find.
16
- pub name: String,
29
+/// A validated, unambiguous automation target: exactly one of an Apple simulator, an
30
+/// Apple ControlKit host (physical device, remote runner, or local Mac), or an
31
+/// Android device. Produced by [`resolve_selected_target`], never constructed
32
+/// directly from untrusted input.
33
+#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
34
+#[serde(tag = "platform", rename_all = "snake_case")]
35
+pub enum SelectedTarget {
36
+ /// An Apple simulator known to `simctl`, identified by name and/or UDID.
37
+ AppleSimulator {
38
+ #[serde(default)]
39
+ simulator_name: Option<String>,
40
+ #[serde(default)]
41
+ simulator_udid: Option<String>,
42
+ /// Local ControlKit JSON-RPC port. Defaults to 12004.
43
+ #[serde(default)]
44
+ controlkit_port: Option<u16>,
45
+ },
46
+ /// A ControlKit JSON-RPC endpoint reachable over the network: a paired physical
47
+ /// device, a remote runner, or a local macOS app.
48
+ AppleHost {
49
+ host: String,
50
+ /// Local ControlKit JSON-RPC port. Defaults to 12004.
51
+ #[serde(default)]
52
+ controlkit_port: Option<u16>,
53
+ },
54
+ /// An Android device or emulator reachable through adb.
55
+ Android {
56
+ /// Device serial from `adb devices -l`. `None` means "the single
57
+ /// authorized device", resolved lazily when the target is used.
58
+ #[serde(default)]
59
+ serial: Option<String>,
60
+ },
61
}
62
19
-#[derive(Debug, Deserialize, JsonSchema)]
20
-pub struct ScreenshotArgs {
21
- /// Simulator name of the target to capture. Omit to use the target selected
22
- /// with the use-target tool.
23
- #[serde(default)]
24
- pub simulator_name: Option<String>,
25
- /// Simulator UDID of the target to capture.
26
- #[serde(default)]
27
- pub simulator_udid: Option<String>,
28
- /// Physical device identifier known to CoreDevice (UDID, ECID, serial
29
- /// number, user-provided name, or DNS name). Provide this to capture a
30
- /// paired physical device instead of a simulator.
31
- #[serde(default)]
32
- pub device: Option<String>,
63
+/// Validate and tag a target from optional per-call fields, falling back to
64
+/// `stored` (the target previously selected with `device_select`) when none of the
65
+/// fields are provided. Returns a plain error message (rather than an MCP error
66
+/// type) so this function stays easy to unit test; callers wrap it for their
67
+/// transport.
68
+///
69
+/// Rejects ambiguous combinations: `android_serial` together with any Apple field,
70
+/// or `simulator_name`/`simulator_udid` together with `host`.
71
+pub fn resolve_selected_target(
72
+ simulator_name: Option<String>,
73
+ simulator_udid: Option<String>,
74
+ host: Option<String>,
75
+ controlkit_port: Option<u16>,
76
+ android_serial: Option<String>,
77
+ stored: Option<SelectedTarget>,
78
+) -> Result<SelectedTarget, String> {
79
+ let apple_simulator_provided = simulator_name.is_some() || simulator_udid.is_some();
80
+ let apple_host_provided = host.is_some();
81
+ let android_provided = android_serial.is_some();
82
+
83
+ if android_provided && (apple_simulator_provided || apple_host_provided) {
84
+ return Err(
85
+ "Ambiguous target: android_serial cannot be combined with simulator_name, \
86
+ simulator_udid, or host. Provide exactly one target."
87
+ .to_string(),
88
+ );
89
+ }
90
+ if apple_simulator_provided && apple_host_provided {
91
+ return Err(
92
+ "Ambiguous target: simulator_name/simulator_udid cannot be combined with host. \
93
+ Provide exactly one target."
94
+ .to_string(),
95
+ );
96
+ }
97
+
98
+ if let Some(serial) = android_serial {
99
+ return Ok(SelectedTarget::Android {
100
+ serial: Some(serial),
101
+ });
102
+ }
103
+ if let Some(host) = host {
104
+ return Ok(SelectedTarget::AppleHost {
105
+ host,
106
+ controlkit_port,
107
+ });
108
+ }
109
+ if apple_simulator_provided {
110
+ return Ok(SelectedTarget::AppleSimulator {
111
+ simulator_name,
112
+ simulator_udid,
113
+ controlkit_port,
114
+ });
115
+ }
116
+
117
+ stored.ok_or_else(|| {
118
+ "No target provided. Pass simulator_name/simulator_udid, host, or android_serial, \
119
+ or select a default target first with device_select."
120
+ .to_string()
121
+ })
122
+}
123
+
124
+/// Reject a resolved target that is Android for a tool that only works on Apple
125
+/// platforms (UI introspection, orientation, macOS click, visionOS spatial tap).
126
+/// Returns a message naming the offending tool so the error is actionable.
127
+pub fn require_apple_target(tool_name: &str, target: &SelectedTarget) -> Result<(), String> {
128
+ match target {
129
+ SelectedTarget::Android { serial } => Err(format!(
130
+ "{tool_name} is Apple-only; the active target is an Android device ({}). \
131
+ Select an Apple simulator or ControlKit host with device_select, or pass \
132
+ simulator_name, simulator_udid, or host explicitly.",
133
+ serial.as_deref().unwrap_or("auto-detected")
134
+ )),
135
+ SelectedTarget::AppleSimulator { .. } | SelectedTarget::AppleHost { .. } => Ok(()),
136
+ }
137
+}
138
+
139
+/// Round a floating-point coordinate to the integer pixel value adb's `input`
140
+/// commands require, rejecting non-finite values instead of silently truncating.
141
+pub fn to_android_coordinate(value: f64) -> Result<i32, String> {
142
+ if !value.is_finite() {
143
+ return Err(format!("coordinate {value} is not a finite number"));
144
+ }
145
+ Ok(value.round() as i32)
146
+}
147
+
148
+/// tvOS/iOS/watchOS remote and hardware buttons reachable through ControlKit's
149
+/// `device.io.button` method.
150
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151
+pub enum AppleButton {
152
+ Up,
153
+ Down,
154
+ Left,
155
+ Right,
156
+ Select,
157
+ Menu,
158
+ Home,
159
+ PlayPause,
160
+}
161
+
162
+impl AppleButton {
163
+ pub const ALL: [&'static str; 8] = [
164
+ "up",
165
+ "down",
166
+ "left",
167
+ "right",
168
+ "select",
169
+ "menu",
170
+ "home",
171
+ "playPause",
172
+ ];
173
+
174
+ pub fn parse(value: &str) -> Option<Self> {
175
+ match value {
176
+ "up" => Some(Self::Up),
177
+ "down" => Some(Self::Down),
178
+ "left" => Some(Self::Left),
179
+ "right" => Some(Self::Right),
180
+ "select" => Some(Self::Select),
181
+ "menu" => Some(Self::Menu),
182
+ "home" => Some(Self::Home),
183
+ "playPause" => Some(Self::PlayPause),
184
+ _ => None,
185
+ }
186
+ }
187
+
188
+ pub fn as_str(self) -> &'static str {
189
+ match self {
190
+ Self::Up => "up",
191
+ Self::Down => "down",
192
+ Self::Left => "left",
193
+ Self::Right => "right",
194
+ Self::Select => "select",
195
+ Self::Menu => "menu",
196
+ Self::Home => "home",
197
+ Self::PlayPause => "playPause",
198
+ }
199
+ }
200
+}
201
+
202
+/// Android system buttons reachable through `adb shell input keyevent`.
203
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204
+pub enum AndroidButton {
205
+ Home,
206
+ Back,
207
+ Enter,
208
+ Recents,
209
+}
210
+
211
+impl AndroidButton {
212
+ pub const ALL: [&'static str; 4] = ["home", "back", "enter", "recents"];
213
+
214
+ pub fn parse(value: &str) -> Option<Self> {
215
+ match value {
216
+ "home" => Some(Self::Home),
217
+ "back" => Some(Self::Back),
218
+ "enter" => Some(Self::Enter),
219
+ "recents" => Some(Self::Recents),
220
+ _ => None,
221
+ }
222
+ }
223
+
224
+ pub fn as_str(self) -> &'static str {
225
+ match self {
226
+ Self::Home => "home",
227
+ Self::Back => "back",
228
+ Self::Enter => "enter",
229
+ Self::Recents => "recents",
230
+ }
231
+ }
232
+}
233
+
234
+/// The button-name vocabulary accepted by `input_button`, spanning both
235
+/// platforms so the tool has a single typed, enumerable field instead of a free
236
+/// string. Which subset is actually valid depends on the resolved target's
237
+/// platform: Apple targets accept `up`, `down`, `left`, `right`, `select`,
238
+/// `menu`, `home`, or `playPause`; Android targets accept `home`, `back`,
239
+/// `enter`, or `recents`. `home` is valid on both. Passing a value from the
240
+/// wrong platform's vocabulary for the resolved target is rejected at call time
241
+/// with an error naming the valid set for that platform.
242
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
243
+#[serde(rename_all = "camelCase")]
244
+pub enum ButtonName {
245
+ Up,
246
+ Down,
247
+ Left,
248
+ Right,
249
+ Select,
250
+ Menu,
251
+ Home,
252
+ PlayPause,
253
+ Back,
254
+ Enter,
255
+ Recents,
256
+}
257
+
258
+impl ButtonName {
259
+ pub fn as_str(self) -> &'static str {
260
+ match self {
261
+ Self::Up => "up",
262
+ Self::Down => "down",
263
+ Self::Left => "left",
264
+ Self::Right => "right",
265
+ Self::Select => "select",
266
+ Self::Menu => "menu",
267
+ Self::Home => "home",
268
+ Self::PlayPause => "playPause",
269
+ Self::Back => "back",
270
+ Self::Enter => "enter",
271
+ Self::Recents => "recents",
272
+ }
273
+ }
274
+}
275
+
276
+/// Screen orientation accepted by `orientation_set`. Apple-only: ControlKit is the
277
+/// only backend that implements orientation control today.
278
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
279
+#[serde(rename_all = "UPPERCASE")]
280
+pub enum Orientation {
281
+ Portrait,
282
+ Landscape,
283
+}
284
+
285
+impl Orientation {
286
+ pub fn as_str(self) -> &'static str {
287
+ match self {
288
+ Self::Portrait => "PORTRAIT",
289
+ Self::Landscape => "LANDSCAPE",
290
+ }
291
+ }
292
+}
293
+
294
+/// Static, hand-maintained capability description for Android targets. Unlike
295
+/// Apple's `device.capabilities` ControlKit call, adb has no capability-discovery
296
+/// RPC, so this mirrors exactly the actions `AndroidDebugBridge` implements.
297
+pub fn android_capabilities() -> serde_json::Value {
298
+ serde_json::json!({
299
+ "screen_capture": true,
300
+ "app_launch": {
301
+ "supported": true,
302
+ "note": "Resolves the Leanback (Android TV) launcher activity first, \
303
+ then falls back to the standard launcher activity.",
304
+ },
305
+ "app_terminate": true,
306
+ "url_open": true,
307
+ "input_tap": true,
308
+ "input_text": true,
309
+ "input_swipe": true,
310
+ "input_button": {
311
+ "supported": true,
312
+ "buttons": AndroidButton::ALL,
313
+ },
314
+ "app_install_launch": false,
315
+ "ui_describe": false,
316
+ "ui_element_list": false,
317
+ "orientation_get": false,
318
+ "orientation_set": false,
319
+ "input_click": false,
320
+ "input_spatial_tap": false,
321
+ })
322
}
323
35
-/// A target chosen once with the use-target tool and reused by later actions so
36
-/// they no longer need per-call target arguments.
37
-#[derive(Debug, Clone, Default, Deserialize, JsonSchema, serde::Serialize)]
38
-pub struct StoredTarget {
324
+/// Target-only arguments shared by `device_select` and `device_capabilities`.
325
+#[derive(Debug, Deserialize, JsonSchema)]
326
+pub struct DeviceSelectArgs {
327
+ /// Exact Apple simulator name to remember as the active target. Mutually
328
+ /// exclusive with `host` and `android_serial`.
329
#[serde(default)]
330
pub simulator_name: Option<String>,
331
+ /// Apple simulator UDID to remember as the active target. Mutually exclusive
332
+ /// with `host` and `android_serial`.
333
#[serde(default)]
334
pub simulator_udid: Option<String>,
335
+ /// ControlKit host of a physical device, remote runner, or local Mac to
336
+ /// remember. Mutually exclusive with `simulator_name`/`simulator_udid` and
337
+ /// `android_serial`.
338
#[serde(default)]
339
pub host: Option<String>,
340
+ /// Local ControlKit JSON-RPC port. Defaults to 12004. Only meaningful with
341
+ /// `simulator_name`/`simulator_udid` or `host`.
342
#[serde(default)]
343
pub controlkit_port: Option<u16>,
344
+ /// Android device serial from `adb devices -l` to remember as the active
345
+ /// target. Mutually exclusive with every Apple field.
346
+ #[serde(default)]
347
+ pub android_serial: Option<String>,
348
+}
349
+
350
+/// Structured result of `device_select`: the single unambiguous target that is
351
+/// now remembered for later calls, until overridden by another `device_select`
352
+/// call or by explicit target fields on a later call.
353
+#[derive(Debug, Clone, Serialize, JsonSchema)]
354
+pub struct DeviceSelectOutput {
355
+ pub selected: SelectedTarget,
356
}
357
358
#[derive(Debug, Deserialize, JsonSchema)]
50
-pub struct UseTargetArgs {
51
- /// Exact simulator name to remember as the active target.
359
+pub struct DeviceCapabilitiesArgs {
360
+ /// Exact Apple simulator name. Omit to use the target selected with
361
+ /// `device_select`.
362
#[serde(default)]
363
pub simulator_name: Option<String>,
54
- /// Simulator UDID to remember as the active target.
364
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
365
#[serde(default)]
366
pub simulator_udid: Option<String>,
57
- /// ControlKit host of a physical device or remote runner to remember.
367
+ /// ControlKit host of a physical device, remote runner, or local Mac.
368
#[serde(default)]
369
pub host: Option<String>,
60
- /// Local ControlKit JSON-RPC port to remember. Defaults to 12004.
370
+ /// Local ControlKit JSON-RPC port. Defaults to 12004.
371
#[serde(default)]
372
pub controlkit_port: Option<u16>,
373
+ /// Android device serial from `adb devices -l`.
374
+ #[serde(default)]
375
+ pub android_serial: Option<String>,
376
}
377
378
#[derive(Debug, Deserialize, JsonSchema)]
66
-pub struct IosAppTestArgs {
67
- /// Exact simulator name to use.
379
+pub struct AppInstallLaunchArgs {
380
+ /// Exact simulator name to use. Provide this or `simulator_udid`.
381
#[serde(default)]
382
pub simulator_name: Option<String>,
70
- /// Simulator UDID to use.
383
+ /// Simulator UDID to use. Provide this or `simulator_name`.
384
#[serde(default)]
385
pub simulator_udid: Option<String>,
73
- /// Path to the `.app` bundle.
386
+ /// Path to the `.app` bundle on disk.
387
pub app_path: PathBuf,
75
- /// Installed app bundle identifier.
388
+ /// Bundle identifier of the app inside `app_path`.
389
pub bundle_id: String,
77
- /// Terminate the app before launching it.
390
+ /// Terminate a previously running instance of the app before launching it.
391
#[serde(default)]
392
pub terminate_before_launch: bool,
393
}
394
395
#[derive(Debug, Deserialize, JsonSchema)]
83
-pub struct SimulatorTargetArgs {
84
- /// Exact simulator name.
396
+pub struct ScreenCaptureArgs {
397
+ /// Exact Apple simulator name. Omit to use the target selected with
398
+ /// `device_select`.
399
#[serde(default)]
400
pub simulator_name: Option<String>,
87
- /// Simulator UDID.
401
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
402
#[serde(default)]
403
pub simulator_udid: Option<String>,
404
+ /// ControlKit host. Screen capture is not implemented for ControlKit hosts;
405
+ /// use `device` instead for a physical device, or select a simulator.
406
+ #[serde(default)]
407
+ pub host: Option<String>,
408
/// Local ControlKit JSON-RPC port. Defaults to 12004.
409
#[serde(default)]
410
pub controlkit_port: Option<u16>,
411
+ /// Physical device identifier known to CoreDevice (UDID, ECID, serial number,
412
+ /// user-provided name, or DNS name). Provide this to capture a paired physical
413
+ /// Apple device instead of a simulator. Mutually exclusive with every other
414
+ /// field.
415
+ #[serde(default)]
416
+ pub device: Option<String>,
417
+ /// Android device serial from `adb devices -l`.
418
+ #[serde(default)]
419
+ pub android_serial: Option<String>,
420
}
421
422
#[derive(Debug, Deserialize, JsonSchema)]
96
-pub struct SimulatorAppArgs {
97
- /// Exact simulator name.
423
+pub struct AppTargetArgs {
424
+ /// Exact Apple simulator name. Omit to use the target selected with
425
+ /// `device_select`.
426
#[serde(default)]
427
pub simulator_name: Option<String>,
100
- /// Simulator UDID.
428
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
429
#[serde(default)]
430
pub simulator_udid: Option<String>,
103
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
431
+ /// ControlKit host of a physical device or remote runner.
432
#[serde(default)]
433
pub host: Option<String>,
434
/// Local ControlKit JSON-RPC port. Defaults to 12004.
435
#[serde(default)]
436
pub controlkit_port: Option<u16>,
109
- /// App bundle identifier.
110
- pub bundle_id: String,
437
+ /// Android device serial from `adb devices -l`.
438
+ #[serde(default)]
439
+ pub android_serial: Option<String>,
440
+ /// App identifier: an Apple bundle identifier, or an Android package name,
441
+ /// depending on the resolved target platform.
442
+ pub app_id: String,
443
}
444
445
#[derive(Debug, Deserialize, JsonSchema)]
114
-pub struct SimulatorOpenUrlArgs {
115
- /// Exact simulator name.
446
+pub struct UrlOpenArgs {
447
+ /// Exact Apple simulator name. Omit to use the target selected with
448
+ /// `device_select`.
449
#[serde(default)]
450
pub simulator_name: Option<String>,
118
- /// Simulator UDID.
451
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
452
#[serde(default)]
453
pub simulator_udid: Option<String>,
454
+ /// ControlKit host. Opening URLs is not implemented for ControlKit hosts;
455
+ /// use a simulator or an Android target instead.
456
+ #[serde(default)]
457
+ pub host: Option<String>,
458
/// Local ControlKit JSON-RPC port. Defaults to 12004.
459
#[serde(default)]
460
pub controlkit_port: Option<u16>,
124
- /// HTTP(S) URL or custom URL scheme.
461
+ /// Android device serial from `adb devices -l`.
462
+ #[serde(default)]
463
+ pub android_serial: Option<String>,
464
+ /// HTTP(S) URL or custom URL scheme to open.
465
pub url: String,
466
}
467
468
#[derive(Debug, Deserialize, JsonSchema)]
129
-pub struct SimulatorTapArgs {
130
- /// Exact simulator name.
469
+pub struct TapArgs {
470
+ /// Exact Apple simulator name. Omit to use the target selected with
471
+ /// `device_select`.
472
#[serde(default)]
473
pub simulator_name: Option<String>,
133
- /// Simulator UDID.
474
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
475
#[serde(default)]
476
pub simulator_udid: Option<String>,
136
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
477
+ /// ControlKit host of a physical device or remote runner.
478
#[serde(default)]
479
pub host: Option<String>,
480
/// Local ControlKit JSON-RPC port. Defaults to 12004.
481
#[serde(default)]
482
pub controlkit_port: Option<u16>,
142
- /// Horizontal screen coordinate.
143
- pub x: f32,
144
- /// Vertical screen coordinate.
145
- pub y: f32,
483
+ /// Android device serial from `adb devices -l`.
484
+ #[serde(default)]
485
+ pub android_serial: Option<String>,
486
+ /// Horizontal screen coordinate in points (Apple) or pixels (Android).
487
+ pub x: f64,
488
+ /// Vertical screen coordinate in points (Apple) or pixels (Android).
489
+ pub y: f64,
490
}
491
492
#[derive(Debug, Deserialize, JsonSchema)]
149
-pub struct SimulatorTextArgs {
150
- /// Exact simulator name.
493
+pub struct TextArgs {
494
+ /// Exact Apple simulator name. Omit to use the target selected with
495
+ /// `device_select`.
496
#[serde(default)]
497
pub simulator_name: Option<String>,
153
- /// Simulator UDID.
498
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
499
#[serde(default)]
500
pub simulator_udid: Option<String>,
156
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
501
+ /// ControlKit host of a physical device or remote runner.
502
#[serde(default)]
503
pub host: Option<String>,
504
/// Local ControlKit JSON-RPC port. Defaults to 12004.
505
#[serde(default)]
506
pub controlkit_port: Option<u16>,
162
- /// Text to type into the focused field.
507
+ /// Android device serial from `adb devices -l`.
508
+ #[serde(default)]
509
+ pub android_serial: Option<String>,
510
+ /// Single-line text to type into the focused field.
511
pub text: String,
512
}
513
514
#[derive(Debug, Deserialize, JsonSchema)]
167
-pub struct SimulatorSwipeArgs {
168
- /// Exact simulator name.
515
+pub struct SwipeArgs {
516
+ /// Exact Apple simulator name. Omit to use the target selected with
517
+ /// `device_select`.
518
#[serde(default)]
519
pub simulator_name: Option<String>,
171
- /// Simulator UDID.
520
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
521
#[serde(default)]
522
pub simulator_udid: Option<String>,
174
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
523
+ /// ControlKit host of a physical device or remote runner.
524
#[serde(default)]
525
pub host: Option<String>,
526
/// Local ControlKit JSON-RPC port. Defaults to 12004.
527
#[serde(default)]
528
pub controlkit_port: Option<u16>,
529
+ /// Android device serial from `adb devices -l`.
530
+ #[serde(default)]
531
+ pub android_serial: Option<String>,
532
/// Swipe start horizontal coordinate.
181
- pub x1: i32,
533
+ pub x1: f64,
534
/// Swipe start vertical coordinate.
183
- pub y1: i32,
535
+ pub y1: f64,
536
/// Swipe end horizontal coordinate.
185
- pub x2: i32,
537
+ pub x2: f64,
538
/// Swipe end vertical coordinate.
187
- pub y2: i32,
188
-}
189
-
190
-#[derive(Debug, Deserialize, JsonSchema)]
191
-pub struct SimulatorOrientationArgs {
192
- /// Exact simulator name.
193
- #[serde(default)]
194
- pub simulator_name: Option<String>,
195
- /// Simulator UDID.
196
- #[serde(default)]
197
- pub simulator_udid: Option<String>,
198
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
199
- #[serde(default)]
200
- pub host: Option<String>,
201
- /// Local ControlKit JSON-RPC port. Defaults to 12004.
539
+ pub y2: f64,
540
+ /// Swipe duration in milliseconds. Defaults to 300. Android only; Apple
541
+ /// ControlKit swipes ignore this field.
542
#[serde(default)]
203
- pub controlkit_port: Option<u16>,
204
- /// Desired orientation: PORTRAIT or LANDSCAPE.
205
- pub orientation: String,
543
+ pub duration_ms: Option<u32>,
544
}
545
546
#[derive(Debug, Deserialize, JsonSchema)]
209
-pub struct SimulatorControlKitArgs {
210
- /// Exact simulator name.
547
+pub struct ButtonArgs {
548
+ /// Exact Apple simulator name. Omit to use the target selected with
549
+ /// `device_select`.
550
#[serde(default)]
551
pub simulator_name: Option<String>,
213
- /// Simulator UDID.
552
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
553
#[serde(default)]
554
pub simulator_udid: Option<String>,
216
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
555
+ /// ControlKit host of a physical device or remote runner.
556
#[serde(default)]
557
pub host: Option<String>,
558
/// Local ControlKit JSON-RPC port. Defaults to 12004.
559
#[serde(default)]
560
pub controlkit_port: Option<u16>,
561
+ /// Android device serial from `adb devices -l`.
562
+ #[serde(default)]
563
+ pub android_serial: Option<String>,
564
+ /// Button to press. For Apple targets: up, down, left, right, select, menu,
565
+ /// home, or playPause. For Android targets: home, back, enter, or recents.
566
+ /// Passing a value from the wrong platform's vocabulary is rejected with a
567
+ /// clear error naming the valid set for the resolved target.
568
+ pub button: ButtonName,
569
}
570
571
#[derive(Debug, Deserialize, JsonSchema)]
225
-pub struct SimulatorButtonArgs {
226
- /// Exact simulator name.
572
+pub struct UiTargetArgs {
573
+ /// Exact Apple simulator name. Omit to use the target selected with
574
+ /// `device_select`.
575
#[serde(default)]
576
pub simulator_name: Option<String>,
229
- /// Simulator UDID.
577
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
578
#[serde(default)]
579
pub simulator_udid: Option<String>,
232
- /// ControlKit host for a physical device or remote runner. Omit for a local simulator.
580
+ /// ControlKit host of a physical device or remote runner. Omit for a local
581
+ /// simulator.
582
#[serde(default)]
583
pub host: Option<String>,
584
/// Local ControlKit JSON-RPC port. Defaults to 12004.
585
#[serde(default)]
586
pub controlkit_port: Option<u16>,
238
- /// tvOS button: up, down, left, right, select, menu, home, or playPause.
239
- pub button: String,
587
}
588
589
#[derive(Debug, Deserialize, JsonSchema)]
243
-pub struct ControlKitEndpointArgs {
244
- /// Exact simulator name. Omit this when connecting to a local macOS runner.
590
+pub struct OrientationSetArgs {
591
+ /// Exact Apple simulator name. Omit to use the target selected with
592
+ /// `device_select`.
593
#[serde(default)]
594
pub simulator_name: Option<String>,
247
- /// Simulator UDID. Omit this when connecting to a local macOS runner.
595
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
596
#[serde(default)]
597
pub simulator_udid: Option<String>,
250
- /// ControlKit host. Defaults to 127.0.0.1 for local runners.
598
+ /// ControlKit host of a physical device or remote runner. Omit for a local
599
+ /// simulator.
600
#[serde(default)]
601
pub host: Option<String>,
602
/// Local ControlKit JSON-RPC port. Defaults to 12004.
603
#[serde(default)]
604
pub controlkit_port: Option<u16>,
605
+ /// Desired screen orientation.
606
+ pub orientation: Orientation,
607
}
608
609
#[derive(Debug, Deserialize, JsonSchema)]
259
-pub struct ControlKitCoordinateArgs {
260
- /// Exact simulator name. Omit this when connecting to a local macOS runner.
610
+pub struct CoordinateArgs {
611
+ /// Exact Apple simulator name. Omit to use the target selected with
612
+ /// `device_select`.
613
#[serde(default)]
614
pub simulator_name: Option<String>,
263
- /// Simulator UDID. Omit this when connecting to a local macOS runner.
615
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
616
#[serde(default)]
617
pub simulator_udid: Option<String>,
266
- /// ControlKit host. Defaults to 127.0.0.1 for local runners.
618
+ /// ControlKit host of a physical device or remote runner. Omit for a local
619
+ /// simulator or Mac.
620
#[serde(default)]
621
pub host: Option<String>,
622
/// Local ControlKit JSON-RPC port. Defaults to 12004.
@@ -288,230 +641,254 @@ impl XcrsMcpServer {
641
macro_rules! xcrs_mcp_tools {
642
(
643
$server:ty,
291
- $simulator_list_name:literal,
292
- $simulator_find_name:literal,
293
- $ios_app_test_name:literal,
294
- $simulator_screenshot_name:literal,
295
- $simulator_launch_app_name:literal,
296
- $simulator_terminate_app_name:literal,
297
- $simulator_open_url_name:literal,
298
- $simulator_ui_dump_name:literal,
299
- $simulator_list_elements_name:literal,
300
- $simulator_tap_name:literal,
301
- $simulator_text_name:literal,
302
- $simulator_swipe_name:literal,
303
- $simulator_button_name:literal,
304
- $simulator_orientation_get_name:literal,
305
- $simulator_orientation_set_name:literal,
306
- $controlkit_capabilities_name:literal,
307
- $macos_click_name:literal,
308
- $visionos_spatial_tap_name:literal,
309
- $use_target_name:literal
644
+ $device_list_name:literal,
645
+ $device_select_name:literal,
646
+ $device_capabilities_name:literal,
647
+ $app_install_launch_name:literal,
648
+ $screen_capture_name:literal,
649
+ $app_launch_name:literal,
650
+ $app_terminate_name:literal,
651
+ $url_open_name:literal,
652
+ $ui_describe_name:literal,
653
+ $ui_element_list_name:literal,
654
+ $input_tap_name:literal,
655
+ $input_text_name:literal,
656
+ $input_swipe_name:literal,
657
+ $input_button_name:literal,
658
+ $input_click_name:literal,
659
+ $input_spatial_tap_name:literal,
660
+ $orientation_get_name:literal,
661
+ $orientation_set_name:literal
662
) => {
663
#[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
664
impl $server {
665
fn selected_target_slot(
314
- ) -> &'static ::std::sync::Mutex<Option<$crate::mcp::StoredTarget>> {
315
- static SLOT: ::std::sync::Mutex<Option<$crate::mcp::StoredTarget>> =
666
+ ) -> &'static ::std::sync::Mutex<Option<$crate::mcp::SelectedTarget>> {
667
+ static SLOT: ::std::sync::Mutex<Option<$crate::mcp::SelectedTarget>> =
668
::std::sync::Mutex::new(None);
669
&SLOT
670
}
671
320
- fn stored_target() -> Option<$crate::mcp::StoredTarget> {
672
+ fn stored_target() -> Option<$crate::mcp::SelectedTarget> {
673
Self::selected_target_slot()
674
.lock()
675
.ok()
676
.and_then(|slot| slot.clone())
677
}
678
327
- /// Fill missing target arguments from the target selected with the
328
- /// use-target tool so per-call target fields become optional.
329
- fn resolve_target_fields(
330
- simulator_name: &Option<String>,
331
- simulator_udid: &Option<String>,
332
- host: &Option<String>,
679
+ /// Validate and tag a target from per-call fields, falling back to the
680
+ /// target selected with `device_select` when no field is provided.
681
+ fn dispatch_target(
682
+ simulator_name: Option<String>,
683
+ simulator_udid: Option<String>,
684
+ host: Option<String>,
685
controlkit_port: Option<u16>,
334
- ) -> (Option<String>, Option<String>, Option<String>, Option<u16>) {
335
- if simulator_name.is_none() && simulator_udid.is_none() && host.is_none() {
336
- if let Some(stored) = Self::stored_target() {
337
- return (
338
- stored.simulator_name,
339
- stored.simulator_udid,
340
- stored.host,
341
- controlkit_port.or(stored.controlkit_port),
342
- );
343
- }
344
- }
345
- (
346
- simulator_name.clone(),
347
- simulator_udid.clone(),
348
- host.clone(),
686
+ android_serial: Option<String>,
687
+ ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData>
688
+ {
689
+ $crate::mcp::resolve_selected_target(
690
+ simulator_name,
691
+ simulator_udid,
692
+ host,
693
controlkit_port,
694
+ android_serial,
695
+ Self::stored_target(),
696
)
697
+ .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))
698
}
699
353
- fn simulator_from_target(
354
- target: &$crate::mcp::SimulatorTargetArgs,
700
+ /// Like [`Self::dispatch_target`] but for tools that only support Apple
701
+ /// targets; rejects an Android target (explicit or stored) with a clear
702
+ /// error naming `tool_name`.
703
+ fn dispatch_apple_target(
704
+ tool_name: &str,
705
+ simulator_name: Option<String>,
706
+ simulator_udid: Option<String>,
707
+ host: Option<String>,
708
+ controlkit_port: Option<u16>,
709
+ ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData>
710
+ {
711
+ let target =
712
+ Self::dispatch_target(simulator_name, simulator_udid, host, controlkit_port, None)?;
713
+ $crate::mcp::require_apple_target(tool_name, &target)
714
+ .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
715
+ Ok(target)
716
+ }
717
+
718
+ fn simulator_for(
719
+ simulator_name: &Option<String>,
720
+ simulator_udid: &Option<String>,
721
) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> {
722
let tools = $crate::XcodeCommandLineTools::new();
357
- let (simulator_name, simulator_udid, _host, _port) = Self::resolve_target_fields(
358
- &target.simulator_name,
359
- &target.simulator_udid,
360
- &None,
361
- target.controlkit_port,
362
- );
363
- match (&simulator_udid, &simulator_name) {
364
- (Some(udid), _) => tools
365
- .simctl()
366
- .find_simulator_by_udid(udid)
367
- .map_err(|error| {
368
- ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
369
- }),
370
- (None, Some(name)) => tools
371
- .simctl()
372
- .find_simulator_by_name(name)
373
- .map_err(|error| {
374
- ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
375
- }),
723
+ match (simulator_udid, simulator_name) {
724
+ (Some(udid), _) => tools.simctl().find_simulator_by_udid(udid).map_err(
725
+ |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None),
726
+ ),
727
+ (None, Some(name)) => tools.simctl().find_simulator_by_name(name).map_err(
728
+ |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None),
729
+ ),
730
(None, None) => Err(::rmcp::model::ErrorData::invalid_request(
377
- "No target. Pass simulator_name or simulator_udid, or select one first with the use-target tool.",
731
+ "Apple target requires simulator_name or simulator_udid.",
732
None,
733
)),
734
}
735
}
736
383
- fn controlkit_from_target(
384
- simulator_name: &Option<String>,
385
- simulator_udid: &Option<String>,
386
- host: &Option<String>,
387
- controlkit_port: Option<u16>,
737
+ /// Build the ControlKit client and, when applicable, resolve the
738
+ /// simulator for an Apple target. Callers must not pass an Android
739
+ /// target; use `dispatch_apple_target` or branch on the target first.
740
+ fn controlkit_for_target(
741
+ target: &$crate::mcp::SelectedTarget,
742
) -> ::std::result::Result<
743
(Option<$crate::Simulator>, $crate::ControlKit),
744
::rmcp::model::ErrorData,
745
> {
392
- let (simulator_name, simulator_udid, host, controlkit_port) =
393
- Self::resolve_target_fields(
394
- simulator_name,
395
- simulator_udid,
746
+ match target {
747
+ $crate::mcp::SelectedTarget::AppleHost {
748
host,
749
controlkit_port,
398
- );
399
- if let Some(host) = host {
400
- return Ok((
750
+ } => Ok((
751
None,
402
- $crate::ControlKit::with_host(&host, controlkit_port.unwrap_or(12004)),
403
- ));
752
+ $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)),
753
+ )),
754
+ $crate::mcp::SelectedTarget::AppleSimulator {
755
+ simulator_name,
756
+ simulator_udid,
757
+ controlkit_port,
758
+ } => {
759
+ let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
760
+ Ok((
761
+ Some(simulator),
762
+ $crate::ControlKit::new(controlkit_port.unwrap_or(12004)),
763
+ ))
764
+ }
765
+ $crate::mcp::SelectedTarget::Android { .. } => {
766
+ Err(::rmcp::model::ErrorData::invalid_request(
767
+ "Android targets do not use ControlKit.",
768
+ None,
769
+ ))
770
+ }
771
}
405
- let target = $crate::mcp::SimulatorTargetArgs {
406
- simulator_name,
407
- simulator_udid,
408
- controlkit_port,
409
- };
410
- let simulator = Self::simulator_from_target(&target)?;
411
- Ok((
412
- Some(simulator),
413
- $crate::ControlKit::new(controlkit_port.unwrap_or(12004)),
414
- ))
772
}
773
417
- fn target_label(simulator: &Option<$crate::Simulator>, host: &Option<String>) -> String {
418
- if let Some(simulator) = simulator {
419
- return simulator.name.clone();
774
+ async fn run_android_blocking<T, F>(
775
+ operation: F,
776
+ ) -> ::anyhow::Result<T>
777
+ where
778
+ T: Send + 'static,
779
+ F: FnOnce() -> ::anyhow::Result<T> + Send + 'static,
780
+ {
781
+ ::tokio::task::spawn_blocking(operation)
782
+ .await
783
+ .map_err(|error| ::anyhow::anyhow!("Android adb task failed: {error}"))?
784
+ }
785
+
786
+ async fn android_device_for(
787
+ serial: Option<String>,
788
+ ) -> ::std::result::Result<$crate::AndroidDevice, ::rmcp::model::ErrorData> {
789
+ Self::run_android_blocking(move || {
790
+ $crate::AndroidDebugBridge::new().resolve_device(serial.as_deref())
791
+ })
792
+ .await
793
+ .map_err(|error| {
794
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
795
+ })
796
+ }
797
+
798
+ fn target_label(
799
+ target: &$crate::mcp::SelectedTarget,
800
+ simulator: &Option<$crate::Simulator>,
801
+ ) -> String {
802
+ match target {
803
+ $crate::mcp::SelectedTarget::Android { serial } => serial
804
+ .clone()
805
+ .unwrap_or_else(|| "the connected Android device".to_string()),
806
+ $crate::mcp::SelectedTarget::AppleHost { host, .. } => host.clone(),
807
+ $crate::mcp::SelectedTarget::AppleSimulator { .. } => simulator
808
+ .as_ref()
809
+ .map(|simulator| simulator.name.clone())
810
+ .unwrap_or_else(|| "the selected simulator".to_string()),
811
}
421
- host.clone().unwrap_or_else(|| "127.0.0.1".to_string())
812
}
813
424
- fn controlkit_from_endpoint(
425
- endpoint: &$crate::mcp::ControlKitEndpointArgs,
814
+ #[::rmcp::tool(
815
+ name = $device_list_name,
816
+ title = "List devices",
817
+ annotations(title = "List devices", read_only_hint = true, idempotent_hint = true),
818
+ 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."
819
+ )]
820
+ async fn device_list(
821
+ &self,
822
) -> ::std::result::Result<
427
- ($crate::ControlKit, Option<$crate::Simulator>),
823
+ ::rmcp::model::CallToolResult,
824
::rmcp::model::ErrorData,
825
> {
430
- let (simulator_name, simulator_udid, host, controlkit_port) =
431
- Self::resolve_target_fields(
432
- &endpoint.simulator_name,
433
- &endpoint.simulator_udid,
434
- &endpoint.host,
435
- endpoint.controlkit_port,
436
- );
437
- let simulator = match (&simulator_udid, &simulator_name) {
438
- (Some(udid), _) => Some(
439
- $crate::XcodeCommandLineTools::new()
440
- .simctl()
441
- .find_simulator_by_udid(udid)
442
- .map_err(|error| {
443
- ::rmcp::model::ErrorData::invalid_request(
444
- error.to_string(),
445
- None,
446
- )
447
- })?,
448
- ),
449
- (None, Some(name)) => Some(
450
- $crate::XcodeCommandLineTools::new()
451
- .simctl()
452
- .find_simulator_by_name(name)
453
- .map_err(|error| {
454
- ::rmcp::model::ErrorData::invalid_request(
455
- error.to_string(),
456
- None,
457
- )
458
- })?,
459
- ),
460
- (None, None) => None,
461
- };
462
- let host = host.unwrap_or_else(|| "127.0.0.1".to_string());
463
- Ok((
464
- $crate::ControlKit::with_host(
465
- &host,
466
- controlkit_port.unwrap_or(12004),
826
+ let mut devices = Vec::new();
827
+ let mut errors = ::serde_json::Map::new();
828
+
829
+ match $crate::XcodeCommandLineTools::new().simctl().list_simulators() {
830
+ Ok(simulators) => devices.extend(
831
+ simulators
832
+ .iter()
833
+ .map($crate::NormalizedDevice::from_simulator),
834
),
468
- simulator,
469
- ))
470
- }
835
+ Err(error) => {
836
+ errors.insert("apple".to_string(), ::serde_json::json!(error.to_string()));
837
+ }
838
+ }
839
472
- fn endpoint_from_coordinate(
473
- args: &$crate::mcp::ControlKitCoordinateArgs,
474
- ) -> $crate::mcp::ControlKitEndpointArgs {
475
- $crate::mcp::ControlKitEndpointArgs {
476
- simulator_name: args.simulator_name.clone(),
477
- simulator_udid: args.simulator_udid.clone(),
478
- host: args.host.clone(),
479
- controlkit_port: args.controlkit_port,
840
+ match Self::run_android_blocking(|| {
841
+ $crate::AndroidDebugBridge::new().list_devices()
842
+ })
843
+ .await
844
+ {
845
+ Ok(android_devices) => devices.extend(
846
+ android_devices
847
+ .iter()
848
+ .map($crate::NormalizedDevice::from_android_device),
849
+ ),
850
+ Err(error) => {
851
+ errors.insert("android".to_string(), ::serde_json::json!(error.to_string()));
852
+ }
853
}
854
+
855
+ Ok(::rmcp::model::CallToolResult::success(vec![
856
+ ::rmcp::model::ContentBlock::json(&::serde_json::json!({
857
+ "devices": devices,
858
+ "errors": errors,
859
+ }))?,
860
+ ]))
861
}
862
863
#[::rmcp::tool(
484
- name = $use_target_name,
485
- description = "Select the Apple target (simulator or physical/remote device) that later UI actions use by default, so you don't repeat target arguments on every call. Pass a simulator_name/simulator_udid for a simulator, or a host (plus optional controlkit_port) for a physical device or remote runner. Call this once after picking a target with the list-simulators tool; individual actions can still override it with their own target arguments."
864
+ name = $device_select_name,
865
+ title = "Select active device",
866
+ annotations(title = "Select active device", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
867
+ output_schema = ::rmcp::handler::server::tool::schema_for_type::<$crate::mcp::DeviceSelectOutput>(),
868
+ 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)."
869
)]
487
- async fn use_target(
870
+ async fn device_select(
871
&self,
872
::rmcp::handler::server::wrapper::Parameters(
873
args,
874
): ::rmcp::handler::server::wrapper::Parameters<
492
- $crate::mcp::UseTargetArgs,
875
+ $crate::mcp::DeviceSelectArgs,
876
>,
877
) -> ::std::result::Result<
878
::rmcp::model::CallToolResult,
879
::rmcp::model::ErrorData,
880
> {
498
- if args.simulator_name.is_none()
499
- && args.simulator_udid.is_none()
500
- && args.host.is_none()
501
- {
502
- return Err(::rmcp::model::ErrorData::invalid_request(
503
- "Provide a simulator_name/simulator_udid or a host to select as the active target.",
504
- None,
505
- ));
506
- }
507
- let stored = $crate::mcp::StoredTarget {
508
- simulator_name: args.simulator_name,
509
- simulator_udid: args.simulator_udid,
510
- host: args.host,
511
- controlkit_port: args.controlkit_port,
512
- };
881
+ let target = $crate::mcp::resolve_selected_target(
882
+ args.simulator_name,
883
+ args.simulator_udid,
884
+ args.host,
885
+ args.controlkit_port,
886
+ args.android_serial,
887
+ None,
888
+ )
889
+ .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
890
match Self::selected_target_slot().lock() {
514
- Ok(mut slot) => *slot = Some(stored.clone()),
891
+ Ok(mut slot) => *slot = Some(target.clone()),
892
Err(_) => {
893
return Err(::rmcp::model::ErrorData::internal_error(
894
"Failed to store the selected target.",
@@ -519,569 +896,804 @@ macro_rules! xcrs_mcp_tools {
896
))
897
}
898
}
522
- Ok(::rmcp::model::CallToolResult::success(vec![
523
- ::rmcp::model::ContentBlock::json(&::serde_json::json!({
524
- "selected": stored,
525
- }))?,
526
- ]))
899
+ let output = $crate::mcp::DeviceSelectOutput { selected: target };
900
+ let value = ::serde_json::to_value(&output).map_err(|error| {
901
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
902
+ })?;
903
+ Ok(::rmcp::model::CallToolResult::structured(value))
904
}
905
906
#[::rmcp::tool(
530
- name = $controlkit_capabilities_name,
531
- description = "Report the platform and supported input capabilities of the active Apple target. Use this to discover whether the target supports taps, spatial taps, buttons, or orientation before driving it. Works on a simulator, a physical/remote device, or the local Mac."
907
+ name = $device_capabilities_name,
908
+ title = "Get device capabilities",
909
+ annotations(title = "Get device capabilities", read_only_hint = true, idempotent_hint = true),
910
+ 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."
911
)]
533
- async fn controlkit_capabilities(
912
+ async fn device_capabilities(
913
&self,
914
::rmcp::handler::server::wrapper::Parameters(
915
args,
916
): ::rmcp::handler::server::wrapper::Parameters<
538
- $crate::mcp::ControlKitEndpointArgs,
917
+ $crate::mcp::DeviceCapabilitiesArgs,
918
>,
919
) -> ::std::result::Result<
920
::rmcp::model::CallToolResult,
921
::rmcp::model::ErrorData,
922
> {
544
- let (controlkit, simulator) = Self::controlkit_from_endpoint(&args)?;
545
- let result = controlkit
546
- .call("device.capabilities", ::serde_json::json!({}))
547
- .await
548
- .map_err(|error| {
549
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
550
- })?;
551
- Ok(::rmcp::model::CallToolResult::success(vec![
552
- ::rmcp::model::ContentBlock::json(&::serde_json::json!({
553
- "simulator": simulator,
554
- "capabilities": result,
555
- }))?,
556
- ]))
923
+ let target = Self::dispatch_target(
924
+ args.simulator_name,
925
+ args.simulator_udid,
926
+ args.host,
927
+ args.controlkit_port,
928
+ args.android_serial,
929
+ )?;
930
+ match &target {
931
+ $crate::mcp::SelectedTarget::Android { .. } => {
932
+ Ok(::rmcp::model::CallToolResult::success(vec![
933
+ ::rmcp::model::ContentBlock::json(&::serde_json::json!({
934
+ "platform": "android",
935
+ "capabilities": $crate::mcp::android_capabilities(),
936
+ }))?,
937
+ ]))
938
+ }
939
+ _ => {
940
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
941
+ let result = controlkit
942
+ .call("device.capabilities", ::serde_json::json!({}))
943
+ .await
944
+ .map_err(|error| {
945
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
946
+ })?;
947
+ Ok(::rmcp::model::CallToolResult::success(vec![
948
+ ::rmcp::model::ContentBlock::json(&::serde_json::json!({
949
+ "platform": "apple",
950
+ "simulator": simulator,
951
+ "capabilities": result,
952
+ }))?,
953
+ ]))
954
+ }
955
+ }
956
}
957
958
#[::rmcp::tool(
560
- name = $macos_click_name,
561
- description = "Click the running macOS app at screen coordinates. Use this for macOS targets; use the tap tool for iOS/tvOS/watchOS and the gesture tool for visionOS."
959
+ name = $app_install_launch_name,
960
+ title = "Install and launch iOS app",
961
+ annotations(title = "Install and launch iOS app", read_only_hint = false, destructive_hint = true, idempotent_hint = false),
962
+ 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."
963
)]
563
- async fn macos_click(
964
+ async fn app_install_launch(
965
&self,
966
::rmcp::handler::server::wrapper::Parameters(
967
args,
968
): ::rmcp::handler::server::wrapper::Parameters<
568
- $crate::mcp::ControlKitCoordinateArgs,
969
+ $crate::mcp::AppInstallLaunchArgs,
970
>,
971
) -> ::std::result::Result<
972
::rmcp::model::CallToolResult,
973
::rmcp::model::ErrorData,
974
> {
574
- let endpoint = Self::endpoint_from_coordinate(&args);
575
- let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?;
576
- controlkit
577
- .call(
578
- "device.io.click",
579
- ::serde_json::json!({ "x": args.x, "y": args.y }),
580
- )
581
- .await
975
+ if args.simulator_name.is_none() && args.simulator_udid.is_none() {
976
+ return Err(::rmcp::model::ErrorData::invalid_request(
977
+ "Provide either simulator_name or simulator_udid.",
978
+ None,
979
+ ));
980
+ }
981
+
982
+ let result = $crate::XcodeCommandLineTools::new()
983
+ .run_ios_app_test(&$crate::IosAppTest {
984
+ simulator_name: args.simulator_name,
985
+ simulator_udid: args.simulator_udid,
986
+ app_path: args.app_path,
987
+ bundle_id: args.bundle_id,
988
+ terminate_before_launch: args.terminate_before_launch,
989
+ })
990
.map_err(|error| {
991
::rmcp::model::ErrorData::internal_error(error.to_string(), None)
992
})?;
993
Ok(::rmcp::model::CallToolResult::success(vec![
586
- ::rmcp::model::ContentBlock::text(format!(
587
- "Clicked ({}, {}).",
588
- args.x, args.y
589
- )),
994
+ ::rmcp::model::ContentBlock::json(&result)?,
995
]))
996
}
997
998
#[::rmcp::tool(
594
- name = $visionos_spatial_tap_name,
595
- description = "Perform a spatial tap on the running visionOS app at screen coordinates. Use this for visionOS targets; use the tap tool for iOS/tvOS/watchOS and the click tool for macOS."
999
+ name = $screen_capture_name,
1000
+ title = "Capture screenshot",
1001
+ annotations(title = "Capture screenshot", read_only_hint = true, idempotent_hint = true),
1002
+ 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."
1003
)]
597
- async fn visionos_spatial_tap(
1004
+ async fn screen_capture(
1005
&self,
1006
::rmcp::handler::server::wrapper::Parameters(
1007
args,
1008
): ::rmcp::handler::server::wrapper::Parameters<
602
- $crate::mcp::ControlKitCoordinateArgs,
1009
+ $crate::mcp::ScreenCaptureArgs,
1010
>,
1011
) -> ::std::result::Result<
1012
::rmcp::model::CallToolResult,
1013
::rmcp::model::ErrorData,
1014
> {
608
- let endpoint = Self::endpoint_from_coordinate(&args);
609
- let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?;
610
- controlkit
611
- .call(
612
- "device.io.spatial.tap",
613
- ::serde_json::json!({ "x": args.x, "y": args.y }),
614
- )
615
- .await
616
- .map_err(|error| {
617
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
618
- })?;
1015
+ if let Some(device) = &args.device {
1016
+ if args.simulator_name.is_some()
1017
+ || args.simulator_udid.is_some()
1018
+ || args.host.is_some()
1019
+ || args.android_serial.is_some()
1020
+ {
1021
+ return Err(::rmcp::model::ErrorData::invalid_request(
1022
+ "device cannot be combined with simulator_name, simulator_udid, host, or android_serial. Provide exactly one target.",
1023
+ None,
1024
+ ));
1025
+ }
1026
+ let screenshot = $crate::XcodeCommandLineTools::new()
1027
+ .devicectl()
1028
+ .screenshot(device)
1029
+ .map_err(|error| {
1030
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1031
+ })?;
1032
+ return Ok(::rmcp::model::CallToolResult::success(vec![
1033
+ ::rmcp::model::ContentBlock::image(
1034
+ $crate::encode_base64(screenshot),
1035
+ "image/png",
1036
+ ),
1037
+ ]));
1038
+ }
1039
+
1040
+ let target = Self::dispatch_target(
1041
+ args.simulator_name,
1042
+ args.simulator_udid,
1043
+ args.host,
1044
+ args.controlkit_port,
1045
+ args.android_serial,
1046
+ )?;
1047
+ let screenshot = match &target {
1048
+ $crate::mcp::SelectedTarget::Android { serial } => {
1049
+ let device = Self::android_device_for(serial.clone()).await?;
1050
+ let device_serial = device.serial.clone();
1051
+ Self::run_android_blocking(move || {
1052
+ $crate::AndroidDebugBridge::new().screenshot(&device_serial)
1053
+ })
1054
+ .await
1055
+ .map_err(|error| {
1056
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1057
+ })?
1058
+ }
1059
+ $crate::mcp::SelectedTarget::AppleSimulator {
1060
+ simulator_name,
1061
+ simulator_udid,
1062
+ ..
1063
+ } => {
1064
+ let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1065
+ $crate::XcodeCommandLineTools::new()
1066
+ .simctl()
1067
+ .screenshot(&simulator.udid)
1068
+ .map_err(|error| {
1069
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1070
+ })?
1071
+ }
1072
+ $crate::mcp::SelectedTarget::AppleHost { host, .. } => {
1073
+ return Err(::rmcp::model::ErrorData::invalid_request(
1074
+ format!(
1075
+ "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."
1076
+ ),
1077
+ None,
1078
+ ));
1079
+ }
1080
+ };
1081
Ok(::rmcp::model::CallToolResult::success(vec![
620
- ::rmcp::model::ContentBlock::text(format!(
621
- "Performed spatial tap at ({}, {}).",
622
- args.x, args.y
623
- )),
1082
+ ::rmcp::model::ContentBlock::image(
1083
+ $crate::encode_base64(screenshot),
1084
+ "image/png",
1085
+ ),
1086
]))
1087
}
1088
1089
#[::rmcp::tool(
628
- name = $simulator_list_name,
629
- description = "List every Apple simulator known to Xcode with its platform, runtime, UDID, availability, and current state. Start here to pick a simulator, then remember it with the use-target tool."
1090
+ name = $app_launch_name,
1091
+ title = "Launch app",
1092
+ annotations(title = "Launch app", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1093
+ 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."
1094
)]
631
- async fn simulator_list(
1095
+ async fn app_launch(
1096
&self,
1097
+ ::rmcp::handler::server::wrapper::Parameters(
1098
+ args,
1099
+ ): ::rmcp::handler::server::wrapper::Parameters<
1100
+ $crate::mcp::AppTargetArgs,
1101
+ >,
1102
) -> ::std::result::Result<
1103
::rmcp::model::CallToolResult,
1104
::rmcp::model::ErrorData,
1105
> {
637
- let simulators = $crate::XcodeCommandLineTools::new()
638
- .simctl()
639
- .list_simulators()
640
- .map_err(|error| {
641
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
642
- })?;
643
- Ok(::rmcp::model::CallToolResult::success(vec![
644
- ::rmcp::model::ContentBlock::json(&simulators)?,
645
- ]))
646
- }
647
-
648
- #[::rmcp::tool(
649
- name = $simulator_find_name,
650
- description = "Find an Apple simulator by its exact name and return its details as JSON. Use this to resolve a UDID before selecting it with the use-target tool."
651
- )]
652
- async fn simulator_find(
653
- &self,
654
- ::rmcp::handler::server::wrapper::Parameters(
655
- args,
656
- ): ::rmcp::handler::server::wrapper::Parameters<
657
- $crate::mcp::SimulatorFindArgs,
658
- >,
659
- ) -> ::std::result::Result<
660
- ::rmcp::model::CallToolResult,
661
- ::rmcp::model::ErrorData,
662
- > {
663
- let simulator = $crate::XcodeCommandLineTools::new()
664
- .simctl()
665
- .find_simulator_by_name(&args.name)
666
- .map_err(|error| {
667
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
668
- })?;
669
- Ok(::rmcp::model::CallToolResult::success(vec![
670
- ::rmcp::model::ContentBlock::json(&simulator)?,
671
- ]))
672
- }
673
-
674
- #[::rmcp::tool(
675
- name = $ios_app_test_name,
676
- description = "Boot an iOS simulator, install an .app bundle, optionally terminate it first, launch it, and return its app container path. Simulator-only end-to-end setup step; use launch-app/terminate-app for an already-installed app."
677
- )]
678
- async fn ios_app_test(
679
- &self,
680
- ::rmcp::handler::server::wrapper::Parameters(
681
- args,
682
- ): ::rmcp::handler::server::wrapper::Parameters<
683
- $crate::mcp::IosAppTestArgs,
684
- >,
685
- ) -> ::std::result::Result<
686
- ::rmcp::model::CallToolResult,
687
- ::rmcp::model::ErrorData,
688
- > {
689
- if args.simulator_name.is_none() && args.simulator_udid.is_none() {
690
- return Err(::rmcp::model::ErrorData::invalid_request(
691
- "Provide either simulator_name or simulator_udid.",
692
- None,
693
- ));
694
- }
695
-
696
- let result = $crate::XcodeCommandLineTools::new()
697
- .run_ios_app_test(&$crate::IosAppTest {
698
- simulator_name: args.simulator_name,
699
- simulator_udid: args.simulator_udid,
700
- app_path: args.app_path,
701
- bundle_id: args.bundle_id,
702
- terminate_before_launch: args.terminate_before_launch,
703
- })
704
- .map_err(|error| {
705
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
706
- })?;
1106
+ let target = Self::dispatch_target(
1107
+ args.simulator_name,
1108
+ args.simulator_udid,
1109
+ args.host,
1110
+ args.controlkit_port,
1111
+ args.android_serial,
1112
+ )?;
1113
+ let label = match &target {
1114
+ $crate::mcp::SelectedTarget::Android { serial } => {
1115
+ let device = Self::android_device_for(serial.clone()).await?;
1116
+ let device_serial = device.serial.clone();
1117
+ let app_id = args.app_id.clone();
1118
+ Self::run_android_blocking(move || {
1119
+ $crate::AndroidDebugBridge::new()
1120
+ .launch_app(&device_serial, &app_id)
1121
+ })
1122
+ .await
1123
+ .map_err(|error| {
1124
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1125
+ })?;
1126
+ device.serial
1127
+ }
1128
+ $crate::mcp::SelectedTarget::AppleHost {
1129
+ host,
1130
+ controlkit_port,
1131
+ } => {
1132
+ let controlkit =
1133
+ $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
1134
+ controlkit
1135
+ .call(
1136
+ "device.apps.launch",
1137
+ ::serde_json::json!({ "bundleId": args.app_id }),
1138
+ )
1139
+ .await
1140
+ .map_err(|error| {
1141
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1142
+ })?;
1143
+ host.clone()
1144
+ }
1145
+ $crate::mcp::SelectedTarget::AppleSimulator {
1146
+ simulator_name,
1147
+ simulator_udid,
1148
+ ..
1149
+ } => {
1150
+ let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1151
+ $crate::XcodeCommandLineTools::new()
1152
+ .simctl()
1153
+ .launch_app(&simulator.udid, &args.app_id)
1154
+ .map_err(|error| {
1155
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1156
+ })?;
1157
+ simulator.name
1158
+ }
1159
+ };
1160
Ok(::rmcp::model::CallToolResult::success(vec![
708
- ::rmcp::model::ContentBlock::json(&result)?,
1161
+ ::rmcp::model::ContentBlock::text(format!(
1162
+ "Launched {} on {}.",
1163
+ args.app_id, label
1164
+ )),
1165
]))
1166
}
1167
1168
#[::rmcp::tool(
713
- name = $simulator_screenshot_name,
714
- description = "Capture a PNG screenshot of the running app. Targets a simulator (via simulator_name/simulator_udid or the selected target) or a paired physical device (via the device identifier). Use describe-ui instead when you need element coordinates rather than pixels."
1169
+ name = $app_terminate_name,
1170
+ title = "Terminate app",
1171
+ annotations(title = "Terminate app", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1172
+ 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."
1173
)]
716
- async fn simulator_screenshot(
1174
+ async fn app_terminate(
1175
&self,
1176
::rmcp::handler::server::wrapper::Parameters(
1177
args,
1178
): ::rmcp::handler::server::wrapper::Parameters<
721
- $crate::mcp::ScreenshotArgs,
1179
+ $crate::mcp::AppTargetArgs,
1180
>,
1181
) -> ::std::result::Result<
1182
::rmcp::model::CallToolResult,
1183
::rmcp::model::ErrorData,
1184
> {
727
- let screenshot = if let Some(device) = &args.device {
728
- $crate::XcodeCommandLineTools::new()
729
- .devicectl()
730
- .screenshot(device)
731
- .map_err(|error| {
732
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
733
- })?
734
- } else {
735
- let target = $crate::mcp::SimulatorTargetArgs {
736
- simulator_name: args.simulator_name.clone(),
737
- simulator_udid: args.simulator_udid.clone(),
738
- controlkit_port: None,
739
- };
740
- let simulator = Self::simulator_from_target(&target)?;
741
- $crate::XcodeCommandLineTools::new()
742
- .simctl()
743
- .screenshot(&simulator.udid)
744
- .map_err(|error| {
745
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
746
- })?
1185
+ let target = Self::dispatch_target(
1186
+ args.simulator_name,
1187
+ args.simulator_udid,
1188
+ args.host,
1189
+ args.controlkit_port,
1190
+ args.android_serial,
1191
+ )?;
1192
+ let label = match &target {
1193
+ $crate::mcp::SelectedTarget::Android { serial } => {
1194
+ let device = Self::android_device_for(serial.clone()).await?;
1195
+ let device_serial = device.serial.clone();
1196
+ let app_id = args.app_id.clone();
1197
+ Self::run_android_blocking(move || {
1198
+ $crate::AndroidDebugBridge::new()
1199
+ .terminate_app(&device_serial, &app_id)
1200
+ })
1201
+ .await
1202
+ .map_err(|error| {
1203
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1204
+ })?;
1205
+ device.serial
1206
+ }
1207
+ $crate::mcp::SelectedTarget::AppleHost {
1208
+ host,
1209
+ controlkit_port,
1210
+ } => {
1211
+ let controlkit =
1212
+ $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
1213
+ controlkit
1214
+ .call(
1215
+ "device.apps.terminate",
1216
+ ::serde_json::json!({ "bundleId": args.app_id }),
1217
+ )
1218
+ .await
1219
+ .map_err(|error| {
1220
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1221
+ })?;
1222
+ host.clone()
1223
+ }
1224
+ $crate::mcp::SelectedTarget::AppleSimulator {
1225
+ simulator_name,
1226
+ simulator_udid,
1227
+ ..
1228
+ } => {
1229
+ let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1230
+ $crate::XcodeCommandLineTools::new()
1231
+ .simctl()
1232
+ .terminate_app(&simulator.udid, &args.app_id)
1233
+ .map_err(|error| {
1234
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1235
+ })?;
1236
+ simulator.name
1237
+ }
1238
};
748
- let encoded = $crate::encode_base64(screenshot);
1239
Ok(::rmcp::model::CallToolResult::success(vec![
750
- ::rmcp::model::ContentBlock::image(encoded, "image/png"),
1240
+ ::rmcp::model::ContentBlock::text(format!(
1241
+ "Terminated {} on {}.",
1242
+ args.app_id, label
1243
+ )),
1244
]))
1245
}
1246
1247
#[::rmcp::tool(
755
- name = $simulator_launch_app_name,
756
- description = "Launch an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device."
1248
+ name = $url_open_name,
1249
+ title = "Open URL",
1250
+ annotations(title = "Open URL", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1251
+ 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)."
1252
)]
758
- async fn simulator_launch_app(
1253
+ async fn url_open(
1254
&self,
1255
::rmcp::handler::server::wrapper::Parameters(
1256
args,
1257
): ::rmcp::handler::server::wrapper::Parameters<
763
- $crate::mcp::SimulatorAppArgs,
1258
+ $crate::mcp::UrlOpenArgs,
1259
>,
1260
) -> ::std::result::Result<
1261
::rmcp::model::CallToolResult,
1262
::rmcp::model::ErrorData,
1263
> {
769
- let (simulator_name, simulator_udid, host, controlkit_port) =
770
- Self::resolve_target_fields(
771
- &args.simulator_name,
772
- &args.simulator_udid,
773
- &args.host,
774
- args.controlkit_port,
775
- );
776
- if let Some(host) = &host {
777
- let controlkit =
778
- $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
779
- controlkit
780
- .call(
781
- "device.apps.launch",
782
- ::serde_json::json!({ "bundleId": args.bundle_id }),
783
- )
784
- .await
785
- .map_err(|error| {
786
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
787
- })?;
788
- return Ok(::rmcp::model::CallToolResult::success(vec![
789
- ::rmcp::model::ContentBlock::text(format!(
790
- "Launched {} on {}.",
791
- args.bundle_id, host
792
- )),
793
- ]));
794
- }
795
- let target = $crate::mcp::SimulatorTargetArgs {
796
- simulator_name,
797
- simulator_udid,
798
- controlkit_port,
1264
+ let target = Self::dispatch_target(
1265
+ args.simulator_name,
1266
+ args.simulator_udid,
1267
+ args.host,
1268
+ args.controlkit_port,
1269
+ args.android_serial,
1270
+ )?;
1271
+ let label = match &target {
1272
+ $crate::mcp::SelectedTarget::Android { serial } => {
1273
+ let device = Self::android_device_for(serial.clone()).await?;
1274
+ let device_serial = device.serial.clone();
1275
+ let url = args.url.clone();
1276
+ Self::run_android_blocking(move || {
1277
+ $crate::AndroidDebugBridge::new().open_url(&device_serial, &url)
1278
+ })
1279
+ .await
1280
+ .map_err(|error| {
1281
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1282
+ })?;
1283
+ device.serial
1284
+ }
1285
+ $crate::mcp::SelectedTarget::AppleSimulator {
1286
+ simulator_name,
1287
+ simulator_udid,
1288
+ ..
1289
+ } => {
1290
+ let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1291
+ $crate::XcodeCommandLineTools::new()
1292
+ .simctl()
1293
+ .open_url(&simulator.udid, &args.url)
1294
+ .map_err(|error| {
1295
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1296
+ })?;
1297
+ simulator.name
1298
+ }
1299
+ $crate::mcp::SelectedTarget::AppleHost { host, .. } => {
1300
+ return Err(::rmcp::model::ErrorData::invalid_request(
1301
+ format!(
1302
+ "url_open over a ControlKit host ({host}) is not supported; use a simulator or an Android target instead."
1303
+ ),
1304
+ None,
1305
+ ));
1306
+ }
1307
};
800
- let simulator = Self::simulator_from_target(&target)?;
801
- $crate::XcodeCommandLineTools::new()
802
- .simctl()
803
- .launch_app(&simulator.udid, &args.bundle_id)
804
- .map_err(|error| {
805
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
806
- })?;
1308
Ok(::rmcp::model::CallToolResult::success(vec![
1309
::rmcp::model::ContentBlock::text(format!(
809
- "Launched {} on {}.",
810
- args.bundle_id, simulator.name
1310
+ "Opened {} on {}.",
1311
+ args.url, label
1312
)),
1313
]))
1314
}
1315
1316
#[::rmcp::tool(
816
- name = $simulator_terminate_app_name,
817
- description = "Terminate an installed app by bundle identifier on the active target. Works on a simulator or a physical/remote device."
1317
+ name = $ui_describe_name,
1318
+ title = "Describe UI",
1319
+ annotations(title = "Describe UI", read_only_hint = true, idempotent_hint = true),
1320
+ 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."
1321
)]
819
- async fn simulator_terminate_app(
1322
+ async fn ui_describe(
1323
&self,
1324
::rmcp::handler::server::wrapper::Parameters(
1325
args,
1326
): ::rmcp::handler::server::wrapper::Parameters<
824
- $crate::mcp::SimulatorAppArgs,
1327
+ $crate::mcp::UiTargetArgs,
1328
>,
1329
) -> ::std::result::Result<
1330
::rmcp::model::CallToolResult,
1331
::rmcp::model::ErrorData,
1332
> {
830
- let (simulator_name, simulator_udid, host, controlkit_port) =
831
- Self::resolve_target_fields(
832
- &args.simulator_name,
833
- &args.simulator_udid,
834
- &args.host,
835
- args.controlkit_port,
836
- );
837
- if let Some(host) = &host {
838
- let controlkit =
839
- $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
840
- controlkit
841
- .call(
842
- "device.apps.terminate",
843
- ::serde_json::json!({ "bundleId": args.bundle_id }),
844
- )
845
- .await
846
- .map_err(|error| {
847
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
848
- })?;
849
- return Ok(::rmcp::model::CallToolResult::success(vec![
850
- ::rmcp::model::ContentBlock::text(format!(
851
- "Terminated {} on {}.",
852
- args.bundle_id, host
853
- )),
854
- ]));
855
- }
856
- let target = $crate::mcp::SimulatorTargetArgs {
857
- simulator_name,
858
- simulator_udid,
859
- controlkit_port,
860
- };
861
- let simulator = Self::simulator_from_target(&target)?;
862
- $crate::XcodeCommandLineTools::new()
863
- .simctl()
864
- .terminate_app(&simulator.udid, &args.bundle_id)
1333
+ let target = Self::dispatch_apple_target(
1334
+ $ui_describe_name,
1335
+ args.simulator_name,
1336
+ args.simulator_udid,
1337
+ args.host,
1338
+ args.controlkit_port,
1339
+ )?;
1340
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1341
+ let result = controlkit
1342
+ .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
1343
+ .await
1344
.map_err(|error| {
1345
::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1346
})?;
1347
Ok(::rmcp::model::CallToolResult::success(vec![
869
- ::rmcp::model::ContentBlock::text(format!(
870
- "Terminated {} on {}.",
871
- args.bundle_id, simulator.name
872
- )),
1348
+ ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1349
+ "simulator": simulator,
1350
+ "ui": result,
1351
+ }))?,
1352
]))
1353
}
1354
1355
#[::rmcp::tool(
877
- name = $simulator_open_url_name,
878
- description = "Open a URL or custom URL scheme on the active simulator target (simulator only)."
1356
+ name = $ui_element_list_name,
1357
+ title = "List UI elements",
1358
+ annotations(title = "List UI elements", read_only_hint = true, idempotent_hint = true),
1359
+ 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."
1360
)]
880
- async fn simulator_open_url(
1361
+ async fn ui_element_list(
1362
&self,
1363
::rmcp::handler::server::wrapper::Parameters(
1364
args,
1365
): ::rmcp::handler::server::wrapper::Parameters<
885
- $crate::mcp::SimulatorOpenUrlArgs,
1366
+ $crate::mcp::UiTargetArgs,
1367
>,
1368
) -> ::std::result::Result<
1369
::rmcp::model::CallToolResult,
1370
::rmcp::model::ErrorData,
1371
> {
891
- let target = $crate::mcp::SimulatorTargetArgs {
892
- simulator_name: args.simulator_name,
893
- simulator_udid: args.simulator_udid,
894
- controlkit_port: args.controlkit_port,
895
- };
896
- let simulator = Self::simulator_from_target(&target)?;
897
- $crate::XcodeCommandLineTools::new()
898
- .simctl()
899
- .open_url(&simulator.udid, &args.url)
1372
+ let target = Self::dispatch_apple_target(
1373
+ $ui_element_list_name,
1374
+ args.simulator_name,
1375
+ args.simulator_udid,
1376
+ args.host,
1377
+ args.controlkit_port,
1378
+ )?;
1379
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1380
+ let ui = controlkit
1381
+ .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
1382
+ .await
1383
.map_err(|error| {
1384
::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1385
})?;
1386
+ let elements = $crate::extract_controlkit_elements(&ui);
1387
Ok(::rmcp::model::CallToolResult::success(vec![
904
- ::rmcp::model::ContentBlock::text(format!(
905
- "Opened {} on {}.",
906
- args.url, simulator.name
907
- )),
1388
+ ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1389
+ "simulator": simulator,
1390
+ "elements": elements,
1391
+ }))?,
1392
]))
1393
}
1394
1395
#[::rmcp::tool(
912
- name = $simulator_ui_dump_name,
913
- description = "Return the full accessibility hierarchy of the foreground app as JSON. Use this first to see what is on screen before tapping or typing. Works on a simulator or a physical/remote device. Prefer list-elements when you only need actionable elements and their tap coordinates."
1396
+ name = $input_tap_name,
1397
+ title = "Tap (iOS/tvOS/Android)",
1398
+ annotations(title = "Tap (iOS/tvOS/Android)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1399
+ 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."
1400
)]
915
- async fn simulator_ui_dump(
1401
+ async fn input_tap(
1402
&self,
1403
::rmcp::handler::server::wrapper::Parameters(
1404
args,
1405
): ::rmcp::handler::server::wrapper::Parameters<
920
- $crate::mcp::SimulatorControlKitArgs,
1406
+ $crate::mcp::TapArgs,
1407
>,
1408
) -> ::std::result::Result<
1409
::rmcp::model::CallToolResult,
1410
::rmcp::model::ErrorData,
1411
> {
926
- let (simulator, controlkit) = Self::controlkit_from_target(
927
- &args.simulator_name,
928
- &args.simulator_udid,
929
- &args.host,
1412
+ let target = Self::dispatch_target(
1413
+ args.simulator_name,
1414
+ args.simulator_udid,
1415
+ args.host,
1416
args.controlkit_port,
1417
+ args.android_serial,
1418
)?;
932
- let result = controlkit
933
- .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
934
- .await
935
- .map_err(|error| {
936
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
937
- })?;
1419
+ let label = match &target {
1420
+ $crate::mcp::SelectedTarget::Android { serial } => {
1421
+ let device = Self::android_device_for(serial.clone()).await?;
1422
+ let x = $crate::mcp::to_android_coordinate(args.x).map_err(|error| {
1423
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1424
+ })?;
1425
+ let y = $crate::mcp::to_android_coordinate(args.y).map_err(|error| {
1426
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1427
+ })?;
1428
+ let device_serial = device.serial.clone();
1429
+ Self::run_android_blocking(move || {
1430
+ $crate::AndroidDebugBridge::new().tap(&device_serial, x, y)
1431
+ })
1432
+ .await
1433
+ .map_err(|error| {
1434
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1435
+ })?;
1436
+ device.serial
1437
+ }
1438
+ _ => {
1439
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1440
+ controlkit
1441
+ .call(
1442
+ "device.io.tap",
1443
+ ::serde_json::json!({ "x": args.x, "y": args.y }),
1444
+ )
1445
+ .await
1446
+ .map_err(|error| {
1447
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1448
+ })?;
1449
+ Self::target_label(&target, &simulator)
1450
+ }
1451
+ };
1452
Ok(::rmcp::model::CallToolResult::success(vec![
939
- ::rmcp::model::ContentBlock::json(&::serde_json::json!({
940
- "simulator": simulator,
941
- "ui": result,
942
- }))?,
1453
+ ::rmcp::model::ContentBlock::text(format!(
1454
+ "Tapped ({}, {}) on {}.",
1455
+ args.x, args.y, label
1456
+ )),
1457
]))
1458
}
1459
1460
#[::rmcp::tool(
947
- name = $simulator_list_elements_name,
948
- description = "List just the actionable accessibility elements of the foreground app with their labels and tap coordinates. Use this to decide where to tap. Works on a simulator or a physical/remote device. Use describe-ui when you need the full hierarchy."
1461
+ name = $input_text_name,
1462
+ title = "Type text",
1463
+ annotations(title = "Type text", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1464
+ 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`."
1465
)]
950
- async fn simulator_list_elements(
1466
+ async fn input_text(
1467
&self,
1468
::rmcp::handler::server::wrapper::Parameters(
1469
args,
1470
): ::rmcp::handler::server::wrapper::Parameters<
955
- $crate::mcp::SimulatorControlKitArgs,
1471
+ $crate::mcp::TextArgs,
1472
>,
1473
) -> ::std::result::Result<
1474
::rmcp::model::CallToolResult,
1475
::rmcp::model::ErrorData,
1476
> {
961
- let (simulator, controlkit) = Self::controlkit_from_target(
962
- &args.simulator_name,
963
- &args.simulator_udid,
964
- &args.host,
1477
+ let target = Self::dispatch_target(
1478
+ args.simulator_name,
1479
+ args.simulator_udid,
1480
+ args.host,
1481
args.controlkit_port,
1482
+ args.android_serial,
1483
)?;
967
- let ui = controlkit
968
- .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
969
- .await
970
- .map_err(|error| {
971
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
972
- })?;
973
- let elements = $crate::extract_controlkit_elements(&ui);
1484
+ let label = match &target {
1485
+ $crate::mcp::SelectedTarget::Android { serial } => {
1486
+ let device = Self::android_device_for(serial.clone()).await?;
1487
+ let device_serial = device.serial.clone();
1488
+ let text = args.text.clone();
1489
+ Self::run_android_blocking(move || {
1490
+ $crate::AndroidDebugBridge::new()
1491
+ .type_text(&device_serial, &text)
1492
+ })
1493
+ .await
1494
+ .map_err(|error| {
1495
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1496
+ })?;
1497
+ device.serial
1498
+ }
1499
+ _ => {
1500
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1501
+ controlkit
1502
+ .call("device.io.text", ::serde_json::json!({ "text": args.text }))
1503
+ .await
1504
+ .map_err(|error| {
1505
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1506
+ })?;
1507
+ Self::target_label(&target, &simulator)
1508
+ }
1509
+ };
1510
Ok(::rmcp::model::CallToolResult::success(vec![
975
- ::rmcp::model::ContentBlock::json(&::serde_json::json!({
976
- "simulator": simulator,
977
- "elements": elements,
978
- }))?,
1511
+ ::rmcp::model::ContentBlock::text(format!("Typed text on {}.", label)),
1512
]))
1513
}
1514
1515
#[::rmcp::tool(
983
- name = $simulator_tap_name,
984
- description = "Tap the running app at screen coordinates. Use this for iOS, tvOS, and watchOS targets, on a simulator or a physical/remote device. Use the click tool for macOS and the gesture tool for visionOS. Read coordinates from list-elements or describe-ui first."
1516
+ name = $input_swipe_name,
1517
+ title = "Swipe",
1518
+ annotations(title = "Swipe", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1519
+ 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."
1520
)]
986
- async fn simulator_tap(
1521
+ async fn input_swipe(
1522
&self,
1523
::rmcp::handler::server::wrapper::Parameters(
1524
args,
1525
): ::rmcp::handler::server::wrapper::Parameters<
991
- $crate::mcp::SimulatorTapArgs,
1526
+ $crate::mcp::SwipeArgs,
1527
>,
1528
) -> ::std::result::Result<
1529
::rmcp::model::CallToolResult,
1530
::rmcp::model::ErrorData,
1531
> {
997
- let (simulator, controlkit) = Self::controlkit_from_target(
998
- &args.simulator_name,
999
- &args.simulator_udid,
1000
- &args.host,
1532
+ let target = Self::dispatch_target(
1533
+ args.simulator_name,
1534
+ args.simulator_udid,
1535
+ args.host,
1536
args.controlkit_port,
1537
+ args.android_serial,
1538
)?;
1003
- controlkit
1004
- .call(
1005
- "device.io.tap",
1006
- ::serde_json::json!({ "x": args.x, "y": args.y }),
1007
- )
1008
- .await
1009
- .map_err(|error| {
1010
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1011
- })?;
1539
+ let label = match &target {
1540
+ $crate::mcp::SelectedTarget::Android { serial } => {
1541
+ let device = Self::android_device_for(serial.clone()).await?;
1542
+ let x1 = $crate::mcp::to_android_coordinate(args.x1).map_err(|error| {
1543
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1544
+ })?;
1545
+ let y1 = $crate::mcp::to_android_coordinate(args.y1).map_err(|error| {
1546
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1547
+ })?;
1548
+ let x2 = $crate::mcp::to_android_coordinate(args.x2).map_err(|error| {
1549
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1550
+ })?;
1551
+ let y2 = $crate::mcp::to_android_coordinate(args.y2).map_err(|error| {
1552
+ ::rmcp::model::ErrorData::invalid_request(error, None)
1553
+ })?;
1554
+ let device_serial = device.serial.clone();
1555
+ let duration_ms = args.duration_ms.unwrap_or(300);
1556
+ Self::run_android_blocking(move || {
1557
+ $crate::AndroidDebugBridge::new()
1558
+ .swipe(&device_serial, x1, y1, x2, y2, duration_ms)
1559
+ })
1560
+ .await
1561
+ .map_err(|error| {
1562
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1563
+ })?;
1564
+ device.serial
1565
+ }
1566
+ _ => {
1567
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1568
+ controlkit
1569
+ .call(
1570
+ "device.io.swipe",
1571
+ ::serde_json::json!({
1572
+ "x1": args.x1,
1573
+ "y1": args.y1,
1574
+ "x2": args.x2,
1575
+ "y2": args.y2,
1576
+ }),
1577
+ )
1578
+ .await
1579
+ .map_err(|error| {
1580
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1581
+ })?;
1582
+ Self::target_label(&target, &simulator)
1583
+ }
1584
+ };
1585
Ok(::rmcp::model::CallToolResult::success(vec![
1013
- ::rmcp::model::ContentBlock::text(format!(
1014
- "Tapped ({}, {}) on {}.",
1015
- args.x, args.y, Self::target_label(&simulator, &args.host)
1016
- )),
1586
+ ::rmcp::model::ContentBlock::text(format!("Swiped on {}.", label)),
1587
]))
1588
}
1589
1590
#[::rmcp::tool(
1021
- name = $simulator_text_name,
1022
- description = "Type text into the focused field of the running app. Works on a simulator or a physical/remote device. Tap the field first to focus it."
1591
+ name = $input_button_name,
1592
+ title = "Press button",
1593
+ annotations(title = "Press button", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1594
+ 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)."
1595
)]
1024
- async fn simulator_text(
1596
+ async fn input_button(
1597
&self,
1598
::rmcp::handler::server::wrapper::Parameters(
1599
args,
1600
): ::rmcp::handler::server::wrapper::Parameters<
1029
- $crate::mcp::SimulatorTextArgs,
1601
+ $crate::mcp::ButtonArgs,
1602
>,
1603
) -> ::std::result::Result<
1604
::rmcp::model::CallToolResult,
1605
::rmcp::model::ErrorData,
1606
> {
1035
- let (simulator, controlkit) = Self::controlkit_from_target(
1036
- &args.simulator_name,
1037
- &args.simulator_udid,
1038
- &args.host,
1607
+ let target = Self::dispatch_target(
1608
+ args.simulator_name,
1609
+ args.simulator_udid,
1610
+ args.host,
1611
args.controlkit_port,
1612
+ args.android_serial,
1613
)?;
1041
- controlkit
1042
- .call("device.io.text", ::serde_json::json!({ "text": args.text }))
1043
- .await
1044
- .map_err(|error| {
1045
- ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1046
- })?;
1614
+ let label = match &target {
1615
+ $crate::mcp::SelectedTarget::Android { serial } => {
1616
+ let button = $crate::mcp::AndroidButton::parse(args.button.as_str()).ok_or_else(|| {
1617
+ ::rmcp::model::ErrorData::invalid_request(
1618
+ format!(
1619
+ "button must be one of {} for an Android target",
1620
+ $crate::mcp::AndroidButton::ALL.join(", ")
1621
+ ),
1622
+ None,
1623
+ )
1624
+ })?;
1625
+ let device = Self::android_device_for(serial.clone()).await?;
1626
+ let device_serial = device.serial.clone();
1627
+ Self::run_android_blocking(move || {
1628
+ $crate::AndroidDebugBridge::new()
1629
+ .press_button(&device_serial, button.as_str())
1630
+ })
1631
+ .await
1632
+ .map_err(|error| {
1633
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1634
+ })?;
1635
+ device.serial
1636
+ }
1637
+ _ => {
1638
+ let button = $crate::mcp::AppleButton::parse(args.button.as_str()).ok_or_else(|| {
1639
+ ::rmcp::model::ErrorData::invalid_request(
1640
+ format!(
1641
+ "button must be one of {} for an Apple target",
1642
+ $crate::mcp::AppleButton::ALL.join(", ")
1643
+ ),
1644
+ None,
1645
+ )
1646
+ })?;
1647
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1648
+ controlkit
1649
+ .call(
1650
+ "device.io.button",
1651
+ ::serde_json::json!({ "button": button.as_str() }),
1652
+ )
1653
+ .await
1654
+ .map_err(|error| {
1655
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1656
+ })?;
1657
+ Self::target_label(&target, &simulator)
1658
+ }
1659
+ };
1660
Ok(::rmcp::model::CallToolResult::success(vec![
1661
::rmcp::model::ContentBlock::text(format!(
1049
- "Typed text on {}.",
1050
- Self::target_label(&simulator, &args.host)
1662
+ "Pressed {} on {}.",
1663
+ args.button.as_str(), label
1664
)),
1665
]))
1666
}
1667
1668
#[::rmcp::tool(
1056
- name = $simulator_swipe_name,
1057
- description = "Swipe between two screen coordinates in the running app, e.g. to scroll. Works on a simulator or a physical/remote device."
1669
+ name = $input_click_name,
1670
+ title = "Click (macOS)",
1671
+ annotations(title = "Click (macOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1672
+ 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)."
1673
)]
1059
- async fn simulator_swipe(
1674
+ async fn input_click(
1675
&self,
1676
::rmcp::handler::server::wrapper::Parameters(
1677
args,
1678
): ::rmcp::handler::server::wrapper::Parameters<
1064
- $crate::mcp::SimulatorSwipeArgs,
1679
+ $crate::mcp::CoordinateArgs,
1680
>,
1681
) -> ::std::result::Result<
1682
::rmcp::model::CallToolResult,
1683
::rmcp::model::ErrorData,
1684
> {
1070
- let (simulator, controlkit) = Self::controlkit_from_target(
1071
- &args.simulator_name,
1072
- &args.simulator_udid,
1073
- &args.host,
1685
+ let target = Self::dispatch_apple_target(
1686
+ $input_click_name,
1687
+ args.simulator_name,
1688
+ args.simulator_udid,
1689
+ args.host,
1690
args.controlkit_port,
1691
)?;
1692
+ let (_, controlkit) = Self::controlkit_for_target(&target)?;
1693
controlkit
1694
.call(
1078
- "device.io.swipe",
1079
- ::serde_json::json!({
1080
- "x1": args.x1,
1081
- "y1": args.y1,
1082
- "x2": args.x2,
1083
- "y2": args.y2,
1084
- }),
1695
+ "device.io.click",
1696
+ ::serde_json::json!({ "x": args.x, "y": args.y }),
1697
)
1698
.await
1699
.map_err(|error| {
@@ -1089,53 +1701,41 @@ macro_rules! xcrs_mcp_tools {
1701
})?;
1702
Ok(::rmcp::model::CallToolResult::success(vec![
1703
::rmcp::model::ContentBlock::text(format!(
1092
- "Swiped on {}.",
1093
- Self::target_label(&simulator, &args.host)
1704
+ "Clicked ({}, {}).",
1705
+ args.x, args.y
1706
)),
1707
]))
1708
}
1709
1710
#[::rmcp::tool(
1099
- name = $simulator_button_name,
1100
- description = "Press a hardware or remote button on the running target: home (iOS), or up, down, left, right, select, menu, playPause (tvOS remote). Works on a simulator or a physical/remote device. Use this to press Home instead of a dedicated tool."
1711
+ name = $input_spatial_tap_name,
1712
+ title = "Spatial tap (visionOS)",
1713
+ annotations(title = "Spatial tap (visionOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1714
+ 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)."
1715
)]
1102
- async fn simulator_button(
1716
+ async fn input_spatial_tap(
1717
&self,
1718
::rmcp::handler::server::wrapper::Parameters(
1719
args,
1720
): ::rmcp::handler::server::wrapper::Parameters<
1107
- $crate::mcp::SimulatorButtonArgs,
1721
+ $crate::mcp::CoordinateArgs,
1722
>,
1723
) -> ::std::result::Result<
1724
::rmcp::model::CallToolResult,
1725
::rmcp::model::ErrorData,
1726
> {
1113
- let (simulator, controlkit) = Self::controlkit_from_target(
1114
- &args.simulator_name,
1115
- &args.simulator_udid,
1116
- &args.host,
1727
+ let target = Self::dispatch_apple_target(
1728
+ $input_spatial_tap_name,
1729
+ args.simulator_name,
1730
+ args.simulator_udid,
1731
+ args.host,
1732
args.controlkit_port,
1733
)?;
1119
- let supported_buttons = [
1120
- "up",
1121
- "down",
1122
- "left",
1123
- "right",
1124
- "select",
1125
- "menu",
1126
- "home",
1127
- "playPause",
1128
- ];
1129
- if !supported_buttons.contains(&args.button.as_str()) {
1130
- return Err(::rmcp::model::ErrorData::invalid_request(
1131
- "button must be one of up, down, left, right, select, menu, home, or playPause",
1132
- None,
1133
- ));
1134
- }
1734
+ let (_, controlkit) = Self::controlkit_for_target(&target)?;
1735
controlkit
1736
.call(
1137
- "device.io.button",
1138
- ::serde_json::json!({ "button": args.button }),
1737
+ "device.io.spatial.tap",
1738
+ ::serde_json::json!({ "x": args.x, "y": args.y }),
1739
)
1740
.await
1741
.map_err(|error| {
@@ -1143,33 +1743,37 @@ macro_rules! xcrs_mcp_tools {
1743
})?;
1744
Ok(::rmcp::model::CallToolResult::success(vec![
1745
::rmcp::model::ContentBlock::text(format!(
1146
- "Pressed {} on {}.",
1147
- args.button, Self::target_label(&simulator, &args.host)
1746
+ "Performed spatial tap at ({}, {}).",
1747
+ args.x, args.y
1748
)),
1749
]))
1750
}
1751
1752
#[::rmcp::tool(
1153
- name = $simulator_orientation_get_name,
1154
- description = "Read the current screen orientation of the running target. Works on a simulator or a physical/remote device."
1753
+ name = $orientation_get_name,
1754
+ title = "Get orientation",
1755
+ annotations(title = "Get orientation", read_only_hint = true, idempotent_hint = true),
1756
+ 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)."
1757
)]
1156
- async fn simulator_orientation_get(
1758
+ async fn orientation_get(
1759
&self,
1760
::rmcp::handler::server::wrapper::Parameters(
1761
args,
1762
): ::rmcp::handler::server::wrapper::Parameters<
1161
- $crate::mcp::SimulatorControlKitArgs,
1763
+ $crate::mcp::UiTargetArgs,
1764
>,
1765
) -> ::std::result::Result<
1766
::rmcp::model::CallToolResult,
1767
::rmcp::model::ErrorData,
1768
> {
1167
- let (simulator, controlkit) = Self::controlkit_from_target(
1168
- &args.simulator_name,
1169
- &args.simulator_udid,
1170
- &args.host,
1769
+ let target = Self::dispatch_apple_target(
1770
+ $orientation_get_name,
1771
+ args.simulator_name,
1772
+ args.simulator_udid,
1773
+ args.host,
1774
args.controlkit_port,
1775
)?;
1776
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1777
let result = controlkit
1778
.call("device.io.orientation.get", ::serde_json::json!({}))
1779
.await
@@ -1185,37 +1789,34 @@ macro_rules! xcrs_mcp_tools {
1789
}
1790
1791
#[::rmcp::tool(
1188
- name = $simulator_orientation_set_name,
1189
- description = "Set the screen orientation of the running target to PORTRAIT or LANDSCAPE. Works on a simulator or a physical/remote device."
1792
+ name = $orientation_set_name,
1793
+ title = "Set orientation",
1794
+ annotations(title = "Set orientation", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1795
+ 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)."
1796
)]
1191
- async fn simulator_orientation_set(
1797
+ async fn orientation_set(
1798
&self,
1799
::rmcp::handler::server::wrapper::Parameters(
1800
args,
1801
): ::rmcp::handler::server::wrapper::Parameters<
1196
- $crate::mcp::SimulatorOrientationArgs,
1802
+ $crate::mcp::OrientationSetArgs,
1803
>,
1804
) -> ::std::result::Result<
1805
::rmcp::model::CallToolResult,
1806
::rmcp::model::ErrorData,
1807
> {
1202
- let (simulator, controlkit) = Self::controlkit_from_target(
1203
- &args.simulator_name,
1204
- &args.simulator_udid,
1205
- &args.host,
1808
+ let target = Self::dispatch_apple_target(
1809
+ $orientation_set_name,
1810
+ args.simulator_name,
1811
+ args.simulator_udid,
1812
+ args.host,
1813
args.controlkit_port,
1814
)?;
1208
- let orientation = args.orientation.to_uppercase();
1209
- if orientation != "PORTRAIT" && orientation != "LANDSCAPE" {
1210
- return Err(::rmcp::model::ErrorData::invalid_request(
1211
- "orientation must be PORTRAIT or LANDSCAPE",
1212
- None,
1213
- ));
1214
- }
1815
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1816
controlkit
1817
.call(
1818
"device.io.orientation.set",
1218
- ::serde_json::json!({ "orientation": orientation }),
1819
+ ::serde_json::json!({ "orientation": args.orientation.as_str() }),
1820
)
1821
.await
1822
.map_err(|error| {
@@ -1224,7 +1825,8 @@ macro_rules! xcrs_mcp_tools {
1825
Ok(::rmcp::model::CallToolResult::success(vec![
1826
::rmcp::model::ContentBlock::text(format!(
1827
"Set orientation to {} on {}.",
1227
- orientation, Self::target_label(&simulator, &args.host)
1828
+ args.orientation.as_str(),
1829
+ Self::target_label(&target, &simulator)
1830
)),
1831
]))
1832
}
@@ -1234,25 +1836,24 @@ macro_rules! xcrs_mcp_tools {
1836
1837
xcrs_mcp_tools!(
1838
XcrsMcpServer,
1237
- "xcrs_list_simulators",
1238
- "xcrs_find_simulator",
1239
- "xcrs_boot_and_install",
1240
- "xcrs_screenshot",
1241
- "xcrs_launch_app",
1242
- "xcrs_terminate_app",
1243
- "xcrs_open_url",
1244
- "xcrs_describe_ui",
1245
- "xcrs_list_elements",
1246
- "xcrs_tap",
1247
- "xcrs_type_text",
1248
- "xcrs_swipe",
1249
- "xcrs_button",
1250
- "xcrs_orientation_get",
1251
- "xcrs_orientation_set",
1252
- "xcrs_capabilities",
1253
- "xcrs_click",
1254
- "xcrs_gesture",
1255
- "xcrs_use_target"
1839
+ "device_list",
1840
+ "device_select",
1841
+ "device_capabilities",
1842
+ "app_install_launch",
1843
+ "screen_capture",
1844
+ "app_launch",
1845
+ "app_terminate",
1846
+ "url_open",
1847
+ "ui_describe",
1848
+ "ui_element_list",
1849
+ "input_tap",
1850
+ "input_text",
1851
+ "input_swipe",
1852
+ "input_button",
1853
+ "input_click",
1854
+ "input_spatial_tap",
1855
+ "orientation_get",
1856
+ "orientation_set"
1857
);
1858
1859
#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
@@ -1265,7 +1866,12 @@ impl ServerHandler for XcrsMcpServer {
1866
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1867
.with_server_info(implementation)
1868
.with_instructions(
1268
- "xcrs exposes Xcode command line tools and ControlKit runners for Apple platform testing.",
1869
+ "xcrs exposes a canonical cross-platform automation contract (device_list, \
1870
+ device_select, device_capabilities, app_install_launch, screen_capture, \
1871
+ app_launch, app_terminate, url_open, ui_describe, ui_element_list, input_tap, \
1872
+ input_text, input_swipe, input_button, input_click, input_spatial_tap, \
1873
+ orientation_get, orientation_set) over Xcode/simctl/devicectl/ControlKit for \
1874
+ Apple platforms and adb for Android.",
1875
)
1876
}
1877
}
@@ -1281,3 +1887,545 @@ pub async fn serve() -> Result<()> {
1887
.map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?;
1888
Ok(())
1889
}
1890
+
1891
+#[cfg(test)]
1892
+mod tests {
1893
+ use super::*;
1894
+
1895
+ /// The 18 canonical tool names shared by the standalone and embedded
1896
+ /// automation servers.
1897
+ const CANONICAL_TOOL_NAMES: [&str; 18] = [
1898
+ "device_list",
1899
+ "device_select",
1900
+ "device_capabilities",
1901
+ "app_install_launch",
1902
+ "screen_capture",
1903
+ "app_launch",
1904
+ "app_terminate",
1905
+ "url_open",
1906
+ "ui_describe",
1907
+ "ui_element_list",
1908
+ "input_tap",
1909
+ "input_text",
1910
+ "input_swipe",
1911
+ "input_button",
1912
+ "input_click",
1913
+ "input_spatial_tap",
1914
+ "orientation_get",
1915
+ "orientation_set",
1916
+ ];
1917
+
1918
+ /// Fully-qualified tool names this crate used before the 18-tool
1919
+ /// consolidation (e.g. `xcrs_list_simulators`, `xcrs_use_target`,
1920
+ /// `xcrs_gesture`). None of these must reappear as a canonical name; a
1921
+ /// match here means a rename regressed back to pre-consolidation naming.
1922
+ const SUPERSEDED_TOOL_NAMES: [&str; 19] = [
1923
+ "xcrs_list_simulators",
1924
+ "xcrs_find_simulator",
1925
+ "xcrs_boot_and_install",
1926
+ "xcrs_screenshot",
1927
+ "xcrs_launch_app",
1928
+ "xcrs_terminate_app",
1929
+ "xcrs_open_url",
1930
+ "xcrs_describe_ui",
1931
+ "xcrs_list_elements",
1932
+ "xcrs_tap",
1933
+ "xcrs_type_text",
1934
+ "xcrs_swipe",
1935
+ "xcrs_button",
1936
+ "xcrs_capabilities",
1937
+ "xcrs_click",
1938
+ "xcrs_gesture",
1939
+ "xcrs_use_target",
1940
+ "xcrs_orientation_get",
1941
+ "xcrs_orientation_set",
1942
+ ];
1943
+
1944
+ /// Tool names whose output is guaranteed to be either image content
1945
+ /// (`screen_capture`), plain text with no structured payload, or a
1946
+ /// passthrough of an arbitrary external JSON-RPC result (ControlKit's
1947
+ /// `device.capabilities`/`device.dump.ui`/`device.io.orientation.get`, or
1948
+ /// simulator/device metadata types owned outside this module). None of
1949
+ /// these can be given a truthful, non-generic `output_schema` without
1950
+ /// either lying about their shape or coupling this module's schema to
1951
+ /// types it does not own, so they intentionally advertise no
1952
+ /// `output_schema`. Only `device_select` returns structured content built
1953
+ /// entirely from types this module owns, so it is the only tool with one.
1954
+ const TOOLS_WITHOUT_OUTPUT_SCHEMA: [&str; 17] = [
1955
+ "device_list",
1956
+ "device_capabilities",
1957
+ "app_install_launch",
1958
+ "screen_capture",
1959
+ "app_launch",
1960
+ "app_terminate",
1961
+ "url_open",
1962
+ "ui_describe",
1963
+ "ui_element_list",
1964
+ "input_tap",
1965
+ "input_text",
1966
+ "input_swipe",
1967
+ "input_button",
1968
+ "input_click",
1969
+ "input_spatial_tap",
1970
+ "orientation_get",
1971
+ "orientation_set",
1972
+ ];
1973
+
1974
+ #[test]
1975
+ fn tool_router_exposes_exactly_the_eighteen_canonical_tools() {
1976
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
1977
+
1978
+ assert_eq!(
1979
+ tools.len(),
1980
+ CANONICAL_TOOL_NAMES.len(),
1981
+ "expected exactly {} canonical tools, found {}",
1982
+ CANONICAL_TOOL_NAMES.len(),
1983
+ tools.len()
1984
+ );
1985
+
1986
+ let mut actual_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
1987
+ actual_names.sort_unstable();
1988
+ let mut expected_names = CANONICAL_TOOL_NAMES;
1989
+ expected_names.sort_unstable();
1990
+ assert_eq!(actual_names, expected_names);
1991
+ }
1992
+
1993
+ #[test]
1994
+ fn tool_router_uses_unprefixed_canonical_names_with_no_legacy_names() {
1995
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
1996
+ for tool in &tools {
1997
+ assert!(
1998
+ !tool.name.starts_with("xcrs_") && !tool.name.starts_with("smb_"),
1999
+ "{} should not repeat a server prefix",
2000
+ tool.name
2001
+ );
2002
+ assert!(
2003
+ !SUPERSEDED_TOOL_NAMES.contains(&tool.name.as_ref()),
2004
+ "{} is a superseded pre-consolidation tool name",
2005
+ tool.name
2006
+ );
2007
+ }
2008
+ }
2009
+
2010
+ #[test]
2011
+ fn every_tool_has_a_meaningful_concise_title() {
2012
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2013
+ for tool in &tools {
2014
+ let title = tool
2015
+ .title
2016
+ .as_deref()
2017
+ .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name));
2018
+ assert!(!title.trim().is_empty(), "{} has a blank title", tool.name);
2019
+ assert!(
2020
+ title.len() <= 40,
2021
+ "{} title should be concise (<= 40 chars), got {title:?}",
2022
+ tool.name
2023
+ );
2024
+ assert_ne!(
2025
+ title,
2026
+ tool.name.as_ref(),
2027
+ "{} title should be a human-readable label, not the raw tool name",
2028
+ tool.name
2029
+ );
2030
+ }
2031
+ }
2032
+
2033
+ #[test]
2034
+ fn every_tool_has_a_front_loaded_transparent_description() {
2035
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2036
+ for tool in &tools {
2037
+ let description = tool
2038
+ .description
2039
+ .as_deref()
2040
+ .unwrap_or_else(|| panic!("{} is missing a description", tool.name));
2041
+ assert!(
2042
+ !description.trim().is_empty(),
2043
+ "{} has a blank description",
2044
+ tool.name
2045
+ );
2046
+ // Purpose Clarity: the purpose must be the first thing a reader sees.
2047
+ assert!(
2048
+ description.starts_with("Purpose:"),
2049
+ "{} description should front-load its purpose",
2050
+ tool.name
2051
+ );
2052
+ // Usage Guidelines: siblings and when-to-use guidance must be present.
2053
+ assert!(
2054
+ description.contains("When to use"),
2055
+ "{} description should say when to use it relative to sibling tools",
2056
+ tool.name
2057
+ );
2058
+ // Behavioral Transparency: behavior, prerequisites, and failure modes.
2059
+ assert!(
2060
+ description.contains("Behavior:"),
2061
+ "{} description should document its behavior",
2062
+ tool.name
2063
+ );
2064
+ assert!(
2065
+ description.contains("Prerequisites:"),
2066
+ "{} description should document its prerequisites",
2067
+ tool.name
2068
+ );
2069
+ assert!(
2070
+ description.contains("Failure modes:"),
2071
+ "{} description should document its failure modes",
2072
+ tool.name
2073
+ );
2074
+ // Not a snapshot: only bound length loosely, so wording can evolve
2075
+ // without brittle exact-text assertions.
2076
+ assert!(
2077
+ description.len() >= 150,
2078
+ "{} description is too terse for behavioral transparency ({} chars)",
2079
+ tool.name,
2080
+ description.len()
2081
+ );
2082
+ }
2083
+ }
2084
+
2085
+ #[test]
2086
+ fn every_tool_has_internally_consistent_annotations() {
2087
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2088
+ for tool in &tools {
2089
+ let annotations = tool
2090
+ .annotations
2091
+ .as_ref()
2092
+ .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
2093
+ let annotations_title = annotations
2094
+ .title
2095
+ .as_deref()
2096
+ .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name));
2097
+ assert_eq!(
2098
+ Some(annotations_title),
2099
+ tool.title.as_deref(),
2100
+ "{} top-level title and annotations title must agree",
2101
+ tool.name
2102
+ );
2103
+ // A tool cannot simultaneously claim to be read-only and to
2104
+ // perform destructive updates.
2105
+ assert!(
2106
+ !(annotations.read_only_hint == Some(true)
2107
+ && annotations.destructive_hint == Some(true)),
2108
+ "{} annotations contradict: read_only_hint and destructive_hint are both true",
2109
+ tool.name
2110
+ );
2111
+ }
2112
+ }
2113
+
2114
+ #[test]
2115
+ fn every_input_schema_property_has_a_description() {
2116
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2117
+ for tool in &tools {
2118
+ let Some(properties) = tool
2119
+ .input_schema
2120
+ .get("properties")
2121
+ .and_then(|value| value.as_object())
2122
+ else {
2123
+ continue;
2124
+ };
2125
+ for (property_name, property_schema) in properties {
2126
+ let description = property_schema
2127
+ .get("description")
2128
+ .and_then(|value| value.as_str())
2129
+ .unwrap_or_default();
2130
+ assert!(
2131
+ !description.trim().is_empty(),
2132
+ "{}.{} is missing a parameter description",
2133
+ tool.name,
2134
+ property_name
2135
+ );
2136
+ }
2137
+ }
2138
+ }
2139
+
2140
+ #[test]
2141
+ fn output_schema_is_present_only_where_structured_content_is_guaranteed() {
2142
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2143
+ assert_eq!(
2144
+ tools.len(),
2145
+ TOOLS_WITHOUT_OUTPUT_SCHEMA.len() + 1,
2146
+ "update TOOLS_WITHOUT_OUTPUT_SCHEMA if the canonical tool set changes"
2147
+ );
2148
+ for tool in &tools {
2149
+ if tool.name.as_ref() == "device_select" {
2150
+ assert!(
2151
+ tool.output_schema.is_some(),
2152
+ "device_select should advertise an output_schema for its fully-owned SelectedTarget payload"
2153
+ );
2154
+ } else {
2155
+ assert!(
2156
+ TOOLS_WITHOUT_OUTPUT_SCHEMA.contains(&tool.name.as_ref()),
2157
+ "{} has an output_schema but is not in the documented intentional-absence list",
2158
+ tool.name
2159
+ );
2160
+ assert!(
2161
+ tool.output_schema.is_none(),
2162
+ "{} should not advertise an output_schema (image, text-only, or externally-owned/passthrough content)",
2163
+ tool.name
2164
+ );
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ #[tokio::test]
2170
+ async fn device_select_structured_content_matches_its_advertised_output_schema() {
2171
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2172
+ let device_select_tool = tools
2173
+ .iter()
2174
+ .find(|tool| tool.name.as_ref() == "device_select")
2175
+ .expect("device_select tool should be registered");
2176
+ let output_schema = device_select_tool
2177
+ .output_schema
2178
+ .as_ref()
2179
+ .expect("device_select should have an output_schema");
2180
+ let required_properties: Vec<&str> = output_schema
2181
+ .get("required")
2182
+ .and_then(|value| value.as_array())
2183
+ .map(|values| values.iter().filter_map(|value| value.as_str()).collect())
2184
+ .unwrap_or_default();
2185
+
2186
+ let result = XcrsMcpServer::new()
2187
+ .device_select(::rmcp::handler::server::wrapper::Parameters(
2188
+ DeviceSelectArgs {
2189
+ simulator_name: Some("iPhone 16".to_string()),
2190
+ simulator_udid: None,
2191
+ host: None,
2192
+ controlkit_port: None,
2193
+ android_serial: None,
2194
+ },
2195
+ ))
2196
+ .await
2197
+ .expect("device_select should succeed with an unambiguous simulator target");
2198
+
2199
+ let structured_content = result
2200
+ .structured_content
2201
+ .expect("device_select should populate structured_content matching its output_schema");
2202
+ let structured_object = structured_content
2203
+ .as_object()
2204
+ .expect("structured_content should be a JSON object");
2205
+ for property in &required_properties {
2206
+ assert!(
2207
+ structured_object.contains_key(*property),
2208
+ "structured_content is missing required property {property}"
2209
+ );
2210
+ }
2211
+ assert_eq!(
2212
+ structured_content["selected"]["platform"],
2213
+ serde_json::json!("apple_simulator")
2214
+ );
2215
+ }
2216
+
2217
+ fn apple_simulator(name: &str) -> SelectedTarget {
2218
+ SelectedTarget::AppleSimulator {
2219
+ simulator_name: Some(name.to_string()),
2220
+ simulator_udid: None,
2221
+ controlkit_port: None,
2222
+ }
2223
+ }
2224
+
2225
+ #[test]
2226
+ fn resolves_android_target_from_explicit_serial() {
2227
+ let target = resolve_selected_target(
2228
+ None,
2229
+ None,
2230
+ None,
2231
+ None,
2232
+ Some("emulator-5554".to_string()),
2233
+ None,
2234
+ )
2235
+ .expect("target should resolve");
2236
+ assert_eq!(
2237
+ target,
2238
+ SelectedTarget::Android {
2239
+ serial: Some("emulator-5554".to_string())
2240
+ }
2241
+ );
2242
+ }
2243
+
2244
+ #[test]
2245
+ fn resolves_apple_simulator_target_from_explicit_name() {
2246
+ let target =
2247
+ resolve_selected_target(Some("iPhone 16".to_string()), None, None, None, None, None)
2248
+ .expect("target should resolve");
2249
+ assert_eq!(target, apple_simulator("iPhone 16"));
2250
+ }
2251
+
2252
+ #[test]
2253
+ fn resolves_apple_host_target_from_explicit_host() {
2254
+ let target = resolve_selected_target(
2255
+ None,
2256
+ None,
2257
+ Some("192.0.2.1".to_string()),
2258
+ Some(12005),
2259
+ None,
2260
+ None,
2261
+ )
2262
+ .expect("target should resolve");
2263
+ assert_eq!(
2264
+ target,
2265
+ SelectedTarget::AppleHost {
2266
+ host: "192.0.2.1".to_string(),
2267
+ controlkit_port: Some(12005),
2268
+ }
2269
+ );
2270
+ }
2271
+
2272
+ #[test]
2273
+ fn falls_back_to_stored_target_when_nothing_provided() {
2274
+ let stored = apple_simulator("iPad");
2275
+ let target = resolve_selected_target(None, None, None, None, None, Some(stored.clone()))
2276
+ .expect("target should resolve");
2277
+ assert_eq!(target, stored);
2278
+ }
2279
+
2280
+ #[test]
2281
+ fn explicit_fields_override_stored_target() {
2282
+ let stored = apple_simulator("iPad");
2283
+ let target = resolve_selected_target(
2284
+ None,
2285
+ None,
2286
+ None,
2287
+ None,
2288
+ Some("emulator-5554".to_string()),
2289
+ Some(stored),
2290
+ )
2291
+ .expect("target should resolve");
2292
+ assert_eq!(
2293
+ target,
2294
+ SelectedTarget::Android {
2295
+ serial: Some("emulator-5554".to_string())
2296
+ }
2297
+ );
2298
+ }
2299
+
2300
+ #[test]
2301
+ fn rejects_missing_target_with_no_stored_fallback() {
2302
+ let error = resolve_selected_target(None, None, None, None, None, None)
2303
+ .expect_err("target should not resolve");
2304
+ assert!(error.contains("No target provided"));
2305
+ }
2306
+
2307
+ #[test]
2308
+ fn rejects_ambiguous_android_and_apple_simulator_fields() {
2309
+ let error = resolve_selected_target(
2310
+ Some("iPhone 16".to_string()),
2311
+ None,
2312
+ None,
2313
+ None,
2314
+ Some("emulator-5554".to_string()),
2315
+ None,
2316
+ )
2317
+ .expect_err("target should not resolve");
2318
+ assert!(error.contains("Ambiguous"));
2319
+ }
2320
+
2321
+ #[test]
2322
+ fn rejects_ambiguous_android_and_apple_host_fields() {
2323
+ let error = resolve_selected_target(
2324
+ None,
2325
+ None,
2326
+ Some("192.0.2.1".to_string()),
2327
+ None,
2328
+ Some("emulator-5554".to_string()),
2329
+ None,
2330
+ )
2331
+ .expect_err("target should not resolve");
2332
+ assert!(error.contains("Ambiguous"));
2333
+ }
2334
+
2335
+ #[test]
2336
+ fn rejects_ambiguous_simulator_and_host_fields() {
2337
+ let error = resolve_selected_target(
2338
+ Some("iPhone 16".to_string()),
2339
+ None,
2340
+ Some("192.0.2.1".to_string()),
2341
+ None,
2342
+ None,
2343
+ None,
2344
+ )
2345
+ .expect_err("target should not resolve");
2346
+ assert!(error.contains("Ambiguous"));
2347
+ }
2348
+
2349
+ #[test]
2350
+ fn requires_apple_target_accepts_apple_simulator() {
2351
+ require_apple_target("ui_describe", &apple_simulator("iPhone 16"))
2352
+ .expect("apple simulator should be accepted");
2353
+ }
2354
+
2355
+ #[test]
2356
+ fn requires_apple_target_accepts_apple_host() {
2357
+ require_apple_target(
2358
+ "ui_describe",
2359
+ &SelectedTarget::AppleHost {
2360
+ host: "192.0.2.1".to_string(),
2361
+ controlkit_port: None,
2362
+ },
2363
+ )
2364
+ .expect("apple host should be accepted");
2365
+ }
2366
+
2367
+ #[test]
2368
+ fn requires_apple_target_rejects_android_with_tool_name_and_serial() {
2369
+ let error = require_apple_target(
2370
+ "ui_describe",
2371
+ &SelectedTarget::Android {
2372
+ serial: Some("emulator-5554".to_string()),
2373
+ },
2374
+ )
2375
+ .expect_err("android target should be rejected");
2376
+ assert!(error.contains("ui_describe"));
2377
+ assert!(error.contains("emulator-5554"));
2378
+ }
2379
+
2380
+ #[test]
2381
+ fn converts_finite_coordinates_to_rounded_pixels() {
2382
+ assert_eq!(to_android_coordinate(12.4).unwrap(), 12);
2383
+ assert_eq!(to_android_coordinate(12.6).unwrap(), 13);
2384
+ // Rust's f64::round() rounds half away from zero.
2385
+ assert_eq!(to_android_coordinate(-3.5).unwrap(), -4);
2386
+ }
2387
+
2388
+ #[test]
2389
+ fn rejects_non_finite_coordinates() {
2390
+ assert!(to_android_coordinate(f64::NAN).is_err());
2391
+ assert!(to_android_coordinate(f64::INFINITY).is_err());
2392
+ }
2393
+
2394
+ #[test]
2395
+ fn parses_valid_apple_buttons_case_sensitively() {
2396
+ assert_eq!(AppleButton::parse("home"), Some(AppleButton::Home));
2397
+ assert_eq!(
2398
+ AppleButton::parse("playPause"),
2399
+ Some(AppleButton::PlayPause)
2400
+ );
2401
+ assert_eq!(AppleButton::parse("playpause"), None);
2402
+ assert_eq!(AppleButton::parse("back"), None);
2403
+ }
2404
+
2405
+ #[test]
2406
+ fn parses_valid_android_buttons() {
2407
+ assert_eq!(AndroidButton::parse("back"), Some(AndroidButton::Back));
2408
+ assert_eq!(
2409
+ AndroidButton::parse("recents"),
2410
+ Some(AndroidButton::Recents)
2411
+ );
2412
+ assert_eq!(AndroidButton::parse("menu"), None);
2413
+ }
2414
+
2415
+ #[test]
2416
+ fn orientation_as_str_matches_controlkit_vocabulary() {
2417
+ assert_eq!(Orientation::Portrait.as_str(), "PORTRAIT");
2418
+ assert_eq!(Orientation::Landscape.as_str(), "LANDSCAPE");
2419
+ }
2420
+
2421
+ #[test]
2422
+ fn android_capabilities_lists_supported_and_unsupported_actions() {
2423
+ let capabilities = android_capabilities();
2424
+ assert_eq!(capabilities["screen_capture"], serde_json::json!(true));
2425
+ assert_eq!(capabilities["ui_describe"], serde_json::json!(false));
2426
+ assert_eq!(
2427
+ capabilities["input_button"]["buttons"],
2428
+ serde_json::json!(AndroidButton::ALL)
2429
+ );
2430
+ }
2431
+}
crates/xcrs/src/xcrs.rs
+525
@@ -1,6 +1,7 @@
1
use anyhow::{anyhow, Context, Result};
2
use serde::{Deserialize, Serialize};
3
use std::collections::BTreeMap;
4
+use std::env;
5
use std::ffi::OsStr;
6
use std::path::{Path, PathBuf};
7
use std::process::{Child, Command, Output, Stdio};
@@ -419,6 +420,385 @@ impl Devicectl<'_> {
420
}
421
}
422
423
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
424
+pub struct AndroidDevice {
425
+ pub serial: String,
426
+ pub state: String,
427
+ pub product: Option<String>,
428
+ pub model: Option<String>,
429
+ pub device: Option<String>,
430
+ pub transport_id: Option<String>,
431
+}
432
+
433
+impl AndroidDevice {
434
+ pub fn is_connected(&self) -> bool {
435
+ self.state == "device"
436
+ }
437
+}
438
+
439
+/// The automation platform a normalized `device_list` entry belongs to.
440
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
441
+#[serde(rename_all = "snake_case")]
442
+pub enum DevicePlatform {
443
+ Apple,
444
+ Android,
445
+}
446
+
447
+/// The kind of device a normalized `device_list` entry represents. Apple physical
448
+/// devices are not represented here: this crate has no reliable Apple
449
+/// device-discovery command, so only Apple simulators are ever listed.
450
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
451
+#[serde(rename_all = "snake_case")]
452
+pub enum DeviceKind {
453
+ Simulator,
454
+ Emulator,
455
+ PhysicalDevice,
456
+}
457
+
458
+/// A device entry normalized across Apple simulators and Android devices/emulators
459
+/// for the cross-platform `device_list` tool.
460
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
461
+pub struct NormalizedDevice {
462
+ pub platform: DevicePlatform,
463
+ pub kind: DeviceKind,
464
+ /// Simulator UDID (Apple) or device serial (Android).
465
+ pub identifier: String,
466
+ pub name: String,
467
+ pub state: String,
468
+ /// The tool used to reach this device: "simctl" or "adb".
469
+ pub transport: String,
470
+}
471
+
472
+impl NormalizedDevice {
473
+ pub fn from_simulator(simulator: &Simulator) -> Self {
474
+ Self {
475
+ platform: DevicePlatform::Apple,
476
+ kind: DeviceKind::Simulator,
477
+ identifier: simulator.udid.clone(),
478
+ name: simulator.name.clone(),
479
+ state: simulator.state.clone(),
480
+ transport: "simctl".to_string(),
481
+ }
482
+ }
483
+
484
+ pub fn from_android_device(device: &AndroidDevice) -> Self {
485
+ let kind = if device.serial.starts_with("emulator-") {
486
+ DeviceKind::Emulator
487
+ } else {
488
+ DeviceKind::PhysicalDevice
489
+ };
490
+ let name = device
491
+ .model
492
+ .clone()
493
+ .or_else(|| device.device.clone())
494
+ .or_else(|| device.product.clone())
495
+ .unwrap_or_else(|| device.serial.clone());
496
+ Self {
497
+ platform: DevicePlatform::Android,
498
+ kind,
499
+ identifier: device.serial.clone(),
500
+ name,
501
+ state: device.state.clone(),
502
+ transport: "adb".to_string(),
503
+ }
504
+ }
505
+}
506
+
507
+#[derive(Debug, Clone)]
508
+pub struct AndroidDebugBridge {
509
+ adb_path: PathBuf,
510
+}
511
+
512
+impl Default for AndroidDebugBridge {
513
+ fn default() -> Self {
514
+ Self {
515
+ adb_path: discover_adb_path(),
516
+ }
517
+ }
518
+}
519
+
520
+impl AndroidDebugBridge {
521
+ pub fn new() -> Self {
522
+ Self::default()
523
+ }
524
+
525
+ pub fn with_path(adb_path: impl Into<PathBuf>) -> Self {
526
+ Self {
527
+ adb_path: adb_path.into(),
528
+ }
529
+ }
530
+
531
+ pub fn list_devices(&self) -> Result<Vec<AndroidDevice>> {
532
+ let output = run_command(&self.adb_path, ["devices", "-l"])?;
533
+ parse_adb_devices(&output)
534
+ }
535
+
536
+ pub fn resolve_device(&self, serial: Option<&str>) -> Result<AndroidDevice> {
537
+ let devices = self.list_devices()?;
538
+ if let Some(serial) = serial {
539
+ let device = devices
540
+ .into_iter()
541
+ .find(|device| device.serial == serial)
542
+ .ok_or_else(|| anyhow!("Android device '{serial}' was not found"))?;
543
+ if !device.is_connected() {
544
+ return Err(anyhow!(
545
+ "Android device '{}' is {}",
546
+ device.serial,
547
+ device.state
548
+ ));
549
+ }
550
+ return Ok(device);
551
+ }
552
+
553
+ let connected_devices = devices
554
+ .into_iter()
555
+ .filter(AndroidDevice::is_connected)
556
+ .collect::<Vec<_>>();
557
+ match connected_devices.as_slice() {
558
+ [device] => Ok(device.clone()),
559
+ [] => Err(anyhow!(
560
+ "no authorized Android device is connected; check `adb devices -l`"
561
+ )),
562
+ _ => Err(anyhow!(
563
+ "multiple Android devices are connected; provide a device serial"
564
+ )),
565
+ }
566
+ }
567
+
568
+ pub fn screenshot(&self, serial: &str) -> Result<Vec<u8>> {
569
+ self.run_for_device_bytes(serial, ["exec-out", "screencap", "-p"])
570
+ }
571
+
572
+ pub fn launch_app(&self, serial: &str, package_name: &str) -> Result<()> {
573
+ validate_android_package_name(package_name)?;
574
+ let component = [
575
+ "android.intent.category.LEANBACK_LAUNCHER",
576
+ "android.intent.category.LAUNCHER",
577
+ ]
578
+ .into_iter()
579
+ .find_map(|category| {
580
+ let output = self
581
+ .run_shell(
582
+ serial,
583
+ &format!(
584
+ "cmd package resolve-activity --brief -a android.intent.action.MAIN -c {category} {}",
585
+ shell_escape(package_name)
586
+ ),
587
+ )
588
+ .ok()?;
589
+ parse_resolved_android_activity(&output)
590
+ })
591
+ .ok_or_else(|| anyhow!("no launchable activity found for Android app '{package_name}'"))?;
592
+
593
+ let output = self.run_shell(
594
+ serial,
595
+ &format!("am start -W -n {}", shell_escape(&component)),
596
+ )?;
597
+ if output.lines().any(|line| line.starts_with("Error:")) {
598
+ return Err(anyhow!(
599
+ "failed to launch Android app '{package_name}': {}",
600
+ output.trim()
601
+ ));
602
+ }
603
+ Ok(())
604
+ }
605
+
606
+ pub fn terminate_app(&self, serial: &str, package_name: &str) -> Result<()> {
607
+ validate_android_package_name(package_name)?;
608
+ self.run_shell(
609
+ serial,
610
+ &format!("am force-stop {}", shell_escape(package_name)),
611
+ )?;
612
+ Ok(())
613
+ }
614
+
615
+ pub fn open_url(&self, serial: &str, url: &str) -> Result<()> {
616
+ if url.is_empty() || url.contains(['\0', '\n', '\r']) {
617
+ return Err(anyhow!("URL must be a non-empty single-line value"));
618
+ }
619
+ self.run_shell(
620
+ serial,
621
+ &format!(
622
+ "am start -a android.intent.action.VIEW -d {}",
623
+ shell_escape(url)
624
+ ),
625
+ )?;
626
+ Ok(())
627
+ }
628
+
629
+ pub fn tap(&self, serial: &str, x: i32, y: i32) -> Result<()> {
630
+ self.run_shell(serial, &format!("input tap {x} {y}"))?;
631
+ Ok(())
632
+ }
633
+
634
+ pub fn type_text(&self, serial: &str, text: &str) -> Result<()> {
635
+ let text = encode_adb_input_text(text)?;
636
+ self.run_shell(serial, &format!("input text {}", shell_escape(&text)))?;
637
+ Ok(())
638
+ }
639
+
640
+ pub fn swipe(
641
+ &self,
642
+ serial: &str,
643
+ x1: i32,
644
+ y1: i32,
645
+ x2: i32,
646
+ y2: i32,
647
+ duration_ms: u32,
648
+ ) -> Result<()> {
649
+ if duration_ms > 10_000 {
650
+ return Err(anyhow!("swipe duration must not exceed 10000 milliseconds"));
651
+ }
652
+ self.run_shell(
653
+ serial,
654
+ &format!("input swipe {x1} {y1} {x2} {y2} {duration_ms}"),
655
+ )?;
656
+ Ok(())
657
+ }
658
+
659
+ pub fn press_button(&self, serial: &str, button: &str) -> Result<()> {
660
+ let keycode = match button {
661
+ "home" => 3,
662
+ "back" => 4,
663
+ "enter" => 66,
664
+ "recents" => 187,
665
+ _ => {
666
+ return Err(anyhow!(
667
+ "button must be one of home, back, enter, or recents"
668
+ ))
669
+ }
670
+ };
671
+ self.run_shell(serial, &format!("input keyevent {keycode}"))?;
672
+ Ok(())
673
+ }
674
+
675
+ fn run_shell(&self, serial: &str, command: &str) -> Result<String> {
676
+ run_command(&self.adb_path, ["-s", serial, "shell", command])
677
+ }
678
+
679
+ fn run_for_device_bytes<I, S>(&self, serial: &str, args: I) -> Result<Vec<u8>>
680
+ where
681
+ I: IntoIterator<Item = S>,
682
+ S: AsRef<OsStr>,
683
+ {
684
+ let output = Command::new(&self.adb_path)
685
+ .arg("-s")
686
+ .arg(serial)
687
+ .args(args)
688
+ .output()
689
+ .with_context(|| format!("failed to run {}", self.adb_path.display()))?;
690
+ command_output_to_bytes(&self.adb_path, output)
691
+ }
692
+}
693
+
694
+fn discover_adb_path() -> PathBuf {
695
+ let executable = if cfg!(windows) { "adb.exe" } else { "adb" };
696
+ let mut candidates = ["ANDROID_SDK_ROOT", "ANDROID_HOME"]
697
+ .into_iter()
698
+ .filter_map(env::var_os)
699
+ .map(PathBuf::from)
700
+ .map(|path| path.join("platform-tools").join(executable))
701
+ .collect::<Vec<_>>();
702
+
703
+ if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
704
+ candidates.push(
705
+ home.join("Library")
706
+ .join("Android")
707
+ .join("sdk")
708
+ .join("platform-tools")
709
+ .join(executable),
710
+ );
711
+ candidates.push(
712
+ home.join("Android")
713
+ .join("Sdk")
714
+ .join("platform-tools")
715
+ .join(executable),
716
+ );
717
+ }
718
+ if let Some(local_app_data) = env::var_os("LOCALAPPDATA").map(PathBuf::from) {
719
+ candidates.push(
720
+ local_app_data
721
+ .join("Android")
722
+ .join("Sdk")
723
+ .join("platform-tools")
724
+ .join(executable),
725
+ );
726
+ }
727
+
728
+ candidates
729
+ .into_iter()
730
+ .find(|path| path.is_file())
731
+ .unwrap_or_else(|| PathBuf::from(executable))
732
+}
733
+
734
+fn parse_adb_devices(output: &str) -> Result<Vec<AndroidDevice>> {
735
+ let mut devices = Vec::new();
736
+ for line in output.lines().map(str::trim) {
737
+ if line.is_empty() || line.starts_with("List of devices attached") || line.starts_with('*')
738
+ {
739
+ continue;
740
+ }
741
+
742
+ let fields = line.split_whitespace().collect::<Vec<_>>();
743
+ let serial = fields
744
+ .first()
745
+ .ok_or_else(|| anyhow!("adb device row did not contain a serial: {line}"))?;
746
+ let state = fields
747
+ .get(1)
748
+ .ok_or_else(|| anyhow!("adb device row did not contain a state: {line}"))?;
749
+ let (state, property_start) = if *state == "no" && fields.get(2) == Some(&"permissions") {
750
+ ("no permissions", 3)
751
+ } else {
752
+ (*state, 2)
753
+ };
754
+ let properties = fields[property_start..]
755
+ .iter()
756
+ .filter_map(|field| field.split_once(':'))
757
+ .collect::<BTreeMap<_, _>>();
758
+
759
+ devices.push(AndroidDevice {
760
+ serial: (*serial).to_string(),
761
+ state: state.to_string(),
762
+ product: properties.get("product").map(|value| (*value).to_string()),
763
+ model: properties.get("model").map(|value| (*value).to_string()),
764
+ device: properties.get("device").map(|value| (*value).to_string()),
765
+ transport_id: properties
766
+ .get("transport_id")
767
+ .map(|value| (*value).to_string()),
768
+ });
769
+ }
770
+ Ok(devices)
771
+}
772
+
773
+fn validate_android_package_name(package_name: &str) -> Result<()> {
774
+ if package_name.is_empty()
775
+ || !package_name
776
+ .chars()
777
+ .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_'))
778
+ {
779
+ return Err(anyhow!(
780
+ "Android package name must contain only ASCII letters, digits, dots, and underscores"
781
+ ));
782
+ }
783
+ Ok(())
784
+}
785
+
786
+fn encode_adb_input_text(text: &str) -> Result<String> {
787
+ if text.is_empty() || text.contains(['\0', '\n', '\r']) {
788
+ return Err(anyhow!("text must be a non-empty single-line value"));
789
+ }
790
+ if text.contains("%s") {
791
+ return Err(anyhow!(
792
+ "text containing the literal sequence '%s' is not supported by adb input"
793
+ ));
794
+ }
795
+ Ok(text.replace(' ', "%s"))
796
+}
797
+
798
+fn shell_escape(value: &str) -> String {
799
+ format!("'{}'", value.replace('\'', "'\\''"))
800
+}
801
+
802
#[derive(Debug, Clone)]
803
pub enum XcodeProject {
804
Project(PathBuf),
@@ -773,6 +1153,44 @@ fn command_output_to_result(program: &Path, output: Output) -> Result<String> {
1153
))
1154
}
1155
1156
+fn command_output_to_bytes(program: &Path, output: Output) -> Result<Vec<u8>> {
1157
+ if output.status.success() {
1158
+ return Ok(output.stdout);
1159
+ }
1160
+
1161
+ let stderr = String::from_utf8_lossy(&output.stderr);
1162
+ let stdout = String::from_utf8_lossy(&output.stdout);
1163
+ let message = [stderr.trim(), stdout.trim()]
1164
+ .into_iter()
1165
+ .filter(|part| !part.is_empty())
1166
+ .collect::<Vec<_>>()
1167
+ .join("\n");
1168
+
1169
+ if message.is_empty() {
1170
+ return Err(anyhow!(
1171
+ "{} exited with status {}",
1172
+ program.display(),
1173
+ output.status
1174
+ ));
1175
+ }
1176
+
1177
+ Err(anyhow!(
1178
+ "{} exited with status {}: {}",
1179
+ program.display(),
1180
+ output.status,
1181
+ message
1182
+ ))
1183
+}
1184
+
1185
+fn parse_resolved_android_activity(output: &str) -> Option<String> {
1186
+ output
1187
+ .lines()
1188
+ .map(str::trim)
1189
+ .rev()
1190
+ .find(|line| !line.is_empty() && line.contains('/') && !line.contains(char::is_whitespace))
1191
+ .map(str::to_string)
1192
+}
1193
+
1194
#[cfg(test)]
1195
mod tests {
1196
use super::*;
@@ -863,4 +1281,111 @@ mod tests {
1281
Some("fd55:33ce:ad87::1")
1282
);
1283
}
1284
+
1285
+ #[test]
1286
+ fn parses_adb_devices() {
1287
+ let output = r#"List of devices attached
1288
+emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
1289
+SERIAL123 unauthorized usb:0-1 transport_id:2
1290
+SERIAL456 no permissions (user denied) usb:0-2 transport_id:3
1291
+"#;
1292
+
1293
+ let devices = parse_adb_devices(output).expect("devices should parse");
1294
+
1295
+ assert_eq!(devices.len(), 3);
1296
+ assert_eq!(devices[0].serial, "emulator-5554");
1297
+ assert_eq!(devices[0].state, "device");
1298
+ assert_eq!(devices[0].model.as_deref(), Some("sdk_gphone64_arm64"));
1299
+ assert_eq!(devices[1].serial, "SERIAL123");
1300
+ assert_eq!(devices[1].state, "unauthorized");
1301
+ assert_eq!(devices[2].serial, "SERIAL456");
1302
+ assert_eq!(devices[2].state, "no permissions");
1303
+ }
1304
+
1305
+ #[test]
1306
+ fn encodes_adb_input_spaces() {
1307
+ let text = encode_adb_input_text("hello android").expect("text should encode");
1308
+ assert_eq!(text, "hello%sandroid");
1309
+ }
1310
+
1311
+ #[test]
1312
+ fn rejects_literal_adb_space_escape() {
1313
+ let error = encode_adb_input_text("literal%svalue").expect_err("text should be rejected");
1314
+ assert!(error.to_string().contains("literal sequence"));
1315
+ }
1316
+
1317
+ #[test]
1318
+ fn parses_resolved_android_activity() {
1319
+ let output = "priority=0 preferredOrder=0 match=0x108000\n\
1320
+ ai.splitfire.KaroKowe/.MainActivity\n";
1321
+
1322
+ assert_eq!(
1323
+ parse_resolved_android_activity(output).as_deref(),
1324
+ Some("ai.splitfire.KaroKowe/.MainActivity")
1325
+ );
1326
+ }
1327
+
1328
+ #[test]
1329
+ fn escapes_android_shell_arguments() {
1330
+ assert_eq!(shell_escape("don't"), "'don'\\''t'");
1331
+ }
1332
+
1333
+ #[test]
1334
+ fn normalizes_apple_simulator_as_simulator_kind() {
1335
+ let simulator = Simulator {
1336
+ platform: ApplePlatform::Ios,
1337
+ runtime_identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0".to_string(),
1338
+ name: "Test-iOS-26".to_string(),
1339
+ udid: "00000000-0000-0000-0000-000000000000".to_string(),
1340
+ state: "Booted".to_string(),
1341
+ is_available: true,
1342
+ };
1343
+
1344
+ let normalized = NormalizedDevice::from_simulator(&simulator);
1345
+
1346
+ assert_eq!(normalized.platform, DevicePlatform::Apple);
1347
+ assert_eq!(normalized.kind, DeviceKind::Simulator);
1348
+ assert_eq!(normalized.identifier, simulator.udid);
1349
+ assert_eq!(normalized.name, "Test-iOS-26");
1350
+ assert_eq!(normalized.state, "Booted");
1351
+ assert_eq!(normalized.transport, "simctl");
1352
+ }
1353
+
1354
+ #[test]
1355
+ fn normalizes_android_emulator_serial_as_emulator_kind() {
1356
+ let device = AndroidDevice {
1357
+ serial: "emulator-5554".to_string(),
1358
+ state: "device".to_string(),
1359
+ product: Some("sdk_gphone64_arm64".to_string()),
1360
+ model: Some("sdk_gphone64_arm64".to_string()),
1361
+ device: Some("emu64a".to_string()),
1362
+ transport_id: Some("1".to_string()),
1363
+ };
1364
+
1365
+ let normalized = NormalizedDevice::from_android_device(&device);
1366
+
1367
+ assert_eq!(normalized.platform, DevicePlatform::Android);
1368
+ assert_eq!(normalized.kind, DeviceKind::Emulator);
1369
+ assert_eq!(normalized.identifier, "emulator-5554");
1370
+ assert_eq!(normalized.name, "sdk_gphone64_arm64");
1371
+ assert_eq!(normalized.transport, "adb");
1372
+ }
1373
+
1374
+ #[test]
1375
+ fn normalizes_android_physical_serial_as_physical_device_kind() {
1376
+ let device = AndroidDevice {
1377
+ serial: "R58N123ABCD".to_string(),
1378
+ state: "device".to_string(),
1379
+ product: None,
1380
+ model: None,
1381
+ device: None,
1382
+ transport_id: None,
1383
+ };
1384
+
1385
+ let normalized = NormalizedDevice::from_android_device(&device);
1386
+
1387
+ assert_eq!(normalized.kind, DeviceKind::PhysicalDevice);
1388
+ // Falls back to the serial when no product/model/device property is reported.
1389
+ assert_eq!(normalized.name, "R58N123ABCD");
1390
+ }
1391
}
docs/controlkit.md
+20
-18
@@ -89,34 +89,36 @@ address with its Bonjour hostname (`<device-name>.local`, discoverable via
89
address (`xcrun devicectl device info details --device <udid>`, under
90
`Tunnel IP Address`).
91
92
-## Connect through `smb --mcp`
92
+## Connect through the automation MCP profile
93
94
-Start the CLI MCP server:
94
+Start either automation server:
95
96
```sh
97
-smb --mcp
97
+xcrs --mcp
98
+# or
99
+smb --mcp --scope automation
100
```
101
102
The runner tools accept either `simulator_name` or `simulator_udid` for a
103
simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a
102
-physical device or any other remote runner. Call `smb_use_target` once to
103
-remember the target so later tools need no target arguments. The standalone
104
-`xcrs --mcp` server exposes the same tools with the `xcrs_` prefix.
104
+physical device or any other remote runner. Call `device_select` once to
105
+remember the target so later tools need no target arguments. Both commands
106
+expose the same unprefixed tool names.
107
108
| Tool | Runner | Purpose |
109
| --- | --- | --- |
108
-| `smb_use_target` | All platforms | Remember the active simulator or device for later tools. |
109
-| `smb_capabilities` | All platforms | Read platform and capability information. |
110
-| `smb_click` | macOS | Click at screen coordinates. |
111
-| `smb_gesture` | visionOS | Perform a spatial tap. |
112
-| `smb_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. |
113
-| `smb_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. |
114
-| `smb_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. |
115
-| `smb_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. |
116
-| `smb_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. |
117
-| `smb_screenshot` | Simulator or physical | Capture a PNG screenshot. Uses `simctl` for a simulator, or `devicectl device capture screenshot` when a `device` identifier is given — the device path works even if the ControlKit runner isn't running. |
118
-| `smb_describe_ui` | All UI-test runners | Read the accessibility hierarchy. |
119
-| `smb_list_elements` | All UI-test runners | Read only actionable elements with tap coordinates. |
110
+| `device_select` | All platforms | Remember the active simulator or device for later tools. |
111
+| `device_capabilities` | All platforms | Read platform and capability information. |
112
+| `input_click` | macOS | Click at screen coordinates. |
113
+| `input_spatial_tap` | visionOS | Perform a spatial tap. |
114
+| `input_tap` | iOS / tvOS / watchOS, simulator or physical | Perform a touch tap. |
115
+| `input_text` | iOS / tvOS, simulator or physical | Type into the focused field. |
116
+| `input_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. |
117
+| `input_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. |
118
+| `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`. |
119
+| `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. |
120
+| `ui_describe` | All UI-test runners | Read the accessibility hierarchy. |
121
+| `ui_element_list` | All UI-test runners | Read only actionable elements with tap coordinates. |
122
123
For a local macOS runner, omit simulator fields and pass `host` and
124
`controlkit_port` when needed:
docs/mcp-registry.md
+2
-2
@@ -2,8 +2,8 @@
2
3
The repository publishes two MCP servers to the [official MCP Registry](https://registry.modelcontextprotocol.io):
4
5
-- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud resources, simulators, and ControlKit runners.
6
-- **`io.github.smbcloudXYZ/xcrs`** — the standalone Rust `xcrs --mcp` server for Xcode and Apple simulator tooling.
5
+- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud projects, Mail, Auth, tenants, and deployments.
6
+- **`io.github.smbcloudXYZ/xcrs`** — **XCRS Mobile & TV Automation**, available through `xcrs --mcp` or `smb --mcp --scope automation` for Xcode, ControlKit, CoreDevice, and adb workflows.
7
8
The registry is a metadata index — it doesn't host binaries. The smbCloud
9
listing points at packages published to npm and NuGet, while the standalone
docs/mcp.md
+75
-49
@@ -2,19 +2,16 @@
2
3
The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI
4
assistant or agent — Claude Desktop, Claude Code, Cursor, or any other
5
-<<<<<<< HEAD
6
-MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and
7
-Auth apps directly, without you leaving the chat to run `smb` commands by hand.
8
-Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 50
9
-tools covering the account, project, tenant, Mail, Auth, simulator, and
10
-ControlKit runner surfaces.
11
-=======
5
MCP-capable client — can send transactional email and manage your smbCloud
6
projects, tenants, Mail apps, and Auth apps directly, without you leaving the
7
chat to run `smb` commands by hand.
8
Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 31
9
tools covering the account, project, tenant, Mail, and Auth surfaces.
17
->>>>>>> development
10
+
11
+Cross-platform mobile and TV app testing is exposed as a separate, focused MCP
12
+profile through `xcrs --mcp` or `smb --mcp --scope automation`. Keeping cloud
13
+and device tools in separate profiles makes tool selection more predictable for
14
+agents.
15
16
This page is the setup guide and the full tool reference. For how `--mcp`
17
compares to the CLI's other two interfaces (headless and `--tui`), see
@@ -251,50 +248,79 @@ users; it belongs to a project.
248
| `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. |
249
| `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** |
250
254
-### Apple platform automation
255
-
256
-The `smb` MCP server also exposes tools for driving Apple apps under test. Most
257
-of the action tools are **host-agnostic**: the same tool works against an Xcode
258
-simulator *or* a physical iOS/tvOS/watchOS/visionOS device *or* the local Mac,
259
-because they talk to an on-target [ControlKit runner](./controlkit.md).
251
+### Mobile and TV app automation
252
261
-**Recommended flow — select a target, then act:**
253
+Start a dedicated automation server instead of mixing device tools into the
254
+cloud profile:
255
263
-1. `smb_list_simulators` (or `smb_find_simulator`) to pick a simulator, or note
264
- a device's `host`.
265
-2. `smb_use_target` once with the chosen `simulator_name`/`simulator_udid` (a
266
- simulator) or `host` + optional `controlkit_port` (a device/remote runner).
267
- The server remembers it, so later action tools need **no** target arguments.
268
-3. `smb_describe_ui` / `smb_list_elements` to see the screen, then
269
- `smb_tap` / `smb_type_text` / `smb_swipe` / `smb_button` and
270
- `smb_screenshot` to drive and verify it.
271
-
272
-Any action tool can still override the selected target inline with its own
273
-`simulator_name`/`simulator_udid` or `host` fields. Simulator arguments accept
274
-either a name or a UDID; ControlKit arguments default to `127.0.0.1:12004`.
256
+```sh
257
+smb --mcp --scope automation
258
+# or install the smaller standalone crate:
259
+xcrs --mcp
260
+```
261
276
-| Tool | Arguments | Returns |
277
-| --- | --- | --- |
278
-| `smb_use_target` | `simulator_name`/`simulator_udid`, or `host` + optional `controlkit_port` | The remembered active target. |
279
-| `smb_list_simulators` | _(none)_ | All Apple simulator devices known to Xcode. |
280
-| `smb_find_simulator` | `name` | A simulator's details (resolve a UDID). |
281
-| `smb_capabilities` | optional target | Target platform and supported input capabilities. |
282
-| `smb_describe_ui` | optional target | Full accessibility hierarchy of the foreground app. |
283
-| `smb_list_elements` | optional target | Actionable accessibility elements with tap coordinates. |
284
-| `smb_tap` | optional target, `x`, `y` | A touch tap (iOS/tvOS/watchOS). |
285
-| `smb_click` | optional endpoint, `x`, `y` | A pointer click (macOS). |
286
-| `smb_gesture` | optional endpoint, `x`, `y` | A spatial tap (visionOS). |
287
-| `smb_type_text` | optional target, `text` | Text typed into the focused field. |
288
-| `smb_swipe` | optional target, `x1`, `y1`, `x2`, `y2` | A swipe. |
289
-| `smb_button` | optional target, `button` | A hardware/remote button press (e.g. `home`). |
290
-| `smb_orientation_get`/`set` | optional target; `orientation` for `set` | Current or updated screen orientation. |
291
-| `smb_launch_app`/`terminate_app` | optional target, `bundle_id` | App lifecycle confirmation (routes through the runner's `device.apps.launch`/`terminate` RPC on a device, `simctl` on a simulator). |
292
-| `smb_open_url` | simulator target, `url` | URL-open confirmation (simulator only). |
293
-| `smb_screenshot` | simulator target, or `device` (UDID/ECID/serial/name/DNS) | A PNG image (`simctl` for a simulator, `devicectl` for a physical device). |
294
-| `smb_boot_and_install` | simulator target, `app_path`, `bundle_id` | Boots a simulator, installs and launches an app (simulator only). |
295
-
296
-The standalone `xcrs --mcp` server exposes the same surface with the `xcrs_`
297
-prefix instead of `smb_`.
262
+Both commands expose the same target-aware tools with the same names. Tool
263
+names do not repeat the server brand because MCP clients already namespace
264
+them by server.
265
+
266
+**Recommended flow:**
267
+
268
+1. Call `device_list` to discover Apple simulators and adb-connected Android
269
+ phones, tablets, emulators, and TV devices.
270
+2. Call `device_select` with the Apple simulator/runner details or Android adb
271
+ serial. Later actions use that target by default.
272
+3. Call `device_capabilities` before choosing an input or inspection action.
273
+4. Use `screen_capture` or the Apple-only `ui_describe`/`ui_element_list` to
274
+ inspect the app, then drive it with the `input_*` tools.
275
+
276
+| Tool | Purpose |
277
+| --- | --- |
278
+| `device_list` | Discover available Apple simulators and Android adb devices. |
279
+| `device_select` | Remember the Apple or Android target used by later actions. |
280
+| `device_capabilities` | Report the selected target's supported automation operations. |
281
+| `app_install_launch` | Boot, install, and launch an iOS simulator app bundle. |
282
+| `app_launch` / `app_terminate` | Start or stop an installed app on the selected target. |
283
+| `screen_capture` | Capture the selected target as a PNG image. |
284
+| `url_open` | Open an HTTP(S) URL or custom scheme where the target supports it. |
285
+| `ui_describe` / `ui_element_list` | Inspect the Apple ControlKit accessibility hierarchy. |
286
+| `input_tap` / `input_text` / `input_swipe` / `input_button` | Drive shared touch, text, gesture, and button actions. |
287
+| `input_click` | Click a macOS target with pointer coordinates. |
288
+| `input_spatial_tap` | Perform a visionOS spatial tap. |
289
+| `orientation_get` / `orientation_set` | Read or update orientation where supported. |
290
+
291
+See [ControlKit runners](./controlkit.md) for Apple runner setup. Android tools
292
+require adb and an authorized device; Android TV app launches resolve the
293
+device's Leanback launcher activity.
294
+
295
+#### Automation migration in 0.5.0
296
+
297
+The automation profile is a clean breaking replacement. Old Apple-default and
298
+Android-prefixed names are not exposed as aliases because duplicate tools make
299
+agent selection less reliable.
300
+
301
+| Previous tools | Replacement |
302
+| --- | --- |
303
+| `smb_list_simulators`, `smb_android_device_list` | `device_list` |
304
+| `smb_use_target` | `device_select` |
305
+| `smb_capabilities` | `device_capabilities` |
306
+| `smb_boot_and_install` | `app_install_launch` |
307
+| `smb_launch_app`, `smb_android_app_launch` | `app_launch` |
308
+| `smb_terminate_app`, `smb_android_app_terminate` | `app_terminate` |
309
+| `smb_screenshot`, `smb_android_screenshot` | `screen_capture` |
310
+| `smb_open_url`, `smb_android_open_url` | `url_open` |
311
+| `smb_describe_ui` | `ui_describe` |
312
+| `smb_list_elements` | `ui_element_list` |
313
+| `smb_tap`, `smb_android_tap` | `input_tap` |
314
+| `smb_type_text`, `smb_android_type_text` | `input_text` |
315
+| `smb_swipe`, `smb_android_swipe` | `input_swipe` |
316
+| `smb_button`, `smb_android_press_button` | `input_button` |
317
+| `smb_click` | `input_click` |
318
+| `smb_gesture` | `input_spatial_tap` |
319
+| `smb_orientation_get` | `orientation_get` |
320
+| `smb_orientation_set` | `orientation_set` |
321
+
322
+The standalone server makes the same replacement without the former `xcrs_`
323
+prefix.
324
325
## Safety: tools run without confirmation
326
server-xcrs.json
+13
-11
@@ -1,8 +1,8 @@
1
{
2
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
"name": "io.github.smbcloudXYZ/xcrs",
4
- "title": "XCRS Xcode Simulator Tools",
5
- "description": "Rust-native Xcode command-line and Apple simulator automation tools exposed through MCP.",
4
+ "title": "XCRS Mobile & TV Automation",
5
+ "description": "Test mobile and TV apps through MCP on iOS simulators, physical Apple devices, Android phones, and Android TV using Xcode, ControlKit, and adb.",
6
"version": "0.4.13",
7
"websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs",
8
"repository": {
@@ -24,7 +24,7 @@
24
{
25
"type": "named",
26
"name": "--mcp",
27
- "description": "Run the XCRS server as an MCP server over stdio.",
27
+ "description": "Run XCRS Mobile & TV Automation as an MCP server over stdio.",
28
"isRequired": true
29
}
30
]
@@ -34,17 +34,19 @@
34
"io.modelcontextprotocol.registry/publisher-provided": {
35
"categories": [
36
"developer-tools",
37
- "xcode",
38
- "ios",
39
- "simulator"
37
+ "testing",
38
+ "automation"
39
],
40
"keywords": [
41
+ "mobile app testing",
42
+ "TV app testing",
43
+ "Android TV automation",
44
+ "Apple TV automation",
45
+ "adb MCP",
46
+ "iOS simulator automation",
47
+ "physical device testing",
48
+ "mobile UI automation",
49
"Xcode",
43
- "iOS simulator",
44
- "tvOS",
45
- "visionOS",
46
- "watchOS",
47
- "macOS",
50
"ControlKit"
51
]
52
}