3 files changed
+98
-9
crates/cli/src/mcp_xcrs.rs
+4
-2
@@ -39,6 +39,7 @@ xcrs::xcrs_mcp_tools!(
39
"url_open",
40
"ui_describe",
41
"ui_element_list",
42
+ "ui_tap",
43
"input_tap",
44
"input_text",
45
"input_swipe",
@@ -89,7 +90,7 @@ pub async fn serve() -> Result<()> {
90
mod tests {
91
use super::*;
92
92
- const CANONICAL_TOOL_NAMES: [&str; 18] = [
93
+ const CANONICAL_TOOL_NAMES: [&str; 19] = [
94
"device_list",
95
"device_select",
96
"device_capabilities",
@@ -100,6 +101,7 @@ mod tests {
101
"url_open",
102
"ui_describe",
103
"ui_element_list",
104
+ "ui_tap",
105
"input_tap",
106
"input_text",
107
"input_swipe",
@@ -111,7 +113,7 @@ mod tests {
113
];
114
115
#[test]
114
- fn automation_router_exposes_exactly_the_canonical_eighteen_tools() {
116
+ fn automation_router_exposes_exactly_the_canonical_nineteen_tools() {
117
let tools = AutomationMcpServer::xcrs_tool_router().list_all();
118
let mut names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
119
names.sort_unstable();
crates/xcrs/src/mcp.rs
+85
-4
@@ -607,6 +607,29 @@ pub struct UiTargetArgs {
607
pub bundle_id: Option<String>,
608
}
609
610
+#[derive(Debug, Deserialize, JsonSchema)]
611
+pub struct UiTapArgs {
612
+ /// Exact Apple simulator name. Omit to use the target selected with
613
+ /// `device_select`.
614
+ #[serde(default)]
615
+ pub simulator_name: Option<String>,
616
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
617
+ #[serde(default)]
618
+ pub simulator_udid: Option<String>,
619
+ /// ControlKit host of a physical device or remote runner. Omit for a local
620
+ /// simulator.
621
+ #[serde(default)]
622
+ pub host: Option<String>,
623
+ /// Local ControlKit JSON-RPC port. Defaults to 12004.
624
+ #[serde(default)]
625
+ pub controlkit_port: Option<u16>,
626
+ /// Bundle identifier of the foreground app that owns the element.
627
+ #[serde(default)]
628
+ pub bundle_id: Option<String>,
629
+ /// Accessibility label, identifier, or value of the element to activate.
630
+ pub element: String,
631
+}
632
+
633
#[derive(Debug, Deserialize, JsonSchema)]
634
pub struct OrientationSetArgs {
635
/// Exact Apple simulator name. Omit to use the target selected with
@@ -672,6 +695,7 @@ macro_rules! xcrs_mcp_tools {
695
$url_open_name:literal,
696
$ui_describe_name:literal,
697
$ui_element_list_name:literal,
698
+ $ui_tap_name:literal,
699
$input_tap_name:literal,
700
$input_text_name:literal,
701
$input_swipe_name:literal,
@@ -1446,6 +1470,60 @@ macro_rules! xcrs_mcp_tools {
1470
]))
1471
}
1472
1473
+ #[::rmcp::tool(
1474
+ name = $ui_tap_name,
1475
+ title = "Tap UI element",
1476
+ annotations(title = "Tap UI element", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1477
+ description = "Purpose: activate a named accessibility element in an Apple app as one action. When to use vs siblings: prefer this for a known button, link, or control; use ui_describe or ui_element_list when you first need to inspect the screen, and input_tap when you specifically need coordinate input. Behavior: forwards bundle_id and element to ControlKit `device.ui.tap`; the runner attaches to the foreground app, requires exactly one element matching its accessibility label, identifier, or value, and activates it with the platform interaction API. On tvOS, the matching element must already have focus and the runner presses Select. Prerequisites: a reachable ControlKit endpoint built from a version that implements `device.ui.tap`; bundle_id must identify the foreground app. Failure modes: errors if no Apple target can be resolved, the app is not foreground, no unique matching element is available, the tvOS element is not focused, or the runner is outdated or unavailable."
1478
+ )]
1479
+ async fn ui_tap(
1480
+ &self,
1481
+ ::rmcp::handler::server::wrapper::Parameters(
1482
+ args,
1483
+ ): ::rmcp::handler::server::wrapper::Parameters<
1484
+ $crate::mcp::UiTapArgs,
1485
+ >,
1486
+ ) -> ::std::result::Result<
1487
+ ::rmcp::model::CallToolResult,
1488
+ ::rmcp::model::ErrorData,
1489
+ > {
1490
+ let bundle_id = Self::require_bundle_id($ui_tap_name, args.bundle_id)?;
1491
+ let element = args.element.trim();
1492
+ if element.is_empty() {
1493
+ return Err(::rmcp::model::ErrorData::invalid_request(
1494
+ format!("{} requires a non-empty element", $ui_tap_name),
1495
+ None,
1496
+ ));
1497
+ }
1498
+ let target = Self::dispatch_apple_target(
1499
+ $ui_tap_name,
1500
+ args.simulator_name,
1501
+ args.simulator_udid,
1502
+ args.host,
1503
+ args.controlkit_port,
1504
+ )?;
1505
+ let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1506
+ controlkit
1507
+ .call(
1508
+ "device.ui.tap",
1509
+ ::serde_json::json!({
1510
+ "bundleId": bundle_id,
1511
+ "element": element,
1512
+ }),
1513
+ )
1514
+ .await
1515
+ .map_err(|error| {
1516
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1517
+ })?;
1518
+ Ok(::rmcp::model::CallToolResult::success(vec![
1519
+ ::rmcp::model::ContentBlock::text(format!(
1520
+ "Activated {} on {}.",
1521
+ element,
1522
+ Self::target_label(&target, &simulator)
1523
+ )),
1524
+ ]))
1525
+ }
1526
+
1527
#[::rmcp::tool(
1528
name = $input_tap_name,
1529
title = "Tap (iOS/tvOS/Android)",
@@ -1900,6 +1978,7 @@ xcrs_mcp_tools!(
1978
"url_open",
1979
"ui_describe",
1980
"ui_element_list",
1981
+ "ui_tap",
1982
"input_tap",
1983
"input_text",
1984
"input_swipe",
@@ -1946,9 +2025,9 @@ pub async fn serve() -> Result<()> {
2025
mod tests {
2026
use super::*;
2027
1949
- /// The 18 canonical tool names shared by the standalone and embedded
2028
+ /// The 19 canonical tool names shared by the standalone and embedded
2029
/// automation servers.
1951
- const CANONICAL_TOOL_NAMES: [&str; 18] = [
2030
+ const CANONICAL_TOOL_NAMES: [&str; 19] = [
2031
"device_list",
2032
"device_select",
2033
"device_capabilities",
@@ -1959,6 +2038,7 @@ mod tests {
2038
"url_open",
2039
"ui_describe",
2040
"ui_element_list",
2041
+ "ui_tap",
2042
"input_tap",
2043
"input_text",
2044
"input_swipe",
@@ -2005,7 +2085,7 @@ mod tests {
2085
/// types it does not own, so they intentionally advertise no
2086
/// `output_schema`. Only `device_select` returns structured content built
2087
/// entirely from types this module owns, so it is the only tool with one.
2008
- const TOOLS_WITHOUT_OUTPUT_SCHEMA: [&str; 17] = [
2088
+ const TOOLS_WITHOUT_OUTPUT_SCHEMA: [&str; 18] = [
2089
"device_list",
2090
"device_capabilities",
2091
"app_install_launch",
@@ -2015,6 +2095,7 @@ mod tests {
2095
"url_open",
2096
"ui_describe",
2097
"ui_element_list",
2098
+ "ui_tap",
2099
"input_tap",
2100
"input_text",
2101
"input_swipe",
@@ -2026,7 +2107,7 @@ mod tests {
2107
];
2108
2109
#[test]
2029
- fn tool_router_exposes_exactly_the_eighteen_canonical_tools() {
2110
+ fn tool_router_exposes_exactly_the_nineteen_canonical_tools() {
2111
let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2112
2113
assert_eq!(
docs/controlkit.md
+9
-3
@@ -119,11 +119,17 @@ expose the same unprefixed tool names.
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 for the supplied `bundle_id`. |
121
| `ui_element_list` | All UI-test runners | Read actionable elements with tap coordinates for the supplied `bundle_id`. |
122
+| `ui_tap` | All UI-test runners | Resolve and activate an accessibility element by label, identifier, or value. |
123
124
UI introspection requires a recent ControlKit runner that implements
124
-`device.dump.ui`. Both UI tools require the target app's `bundle_id`; this
125
-lets XCTest attach to an already-running app instead of accidentally reading
126
-the ControlKit host app.
125
+`device.dump.ui`. The `ui_tap` action requires a runner that implements
126
+`device.ui.tap`; it resolves and activates the requested element inside the
127
+target app, so an MCP client does not need to turn accessibility coordinates
128
+into a second action. The label, identifier, or value must match exactly one
129
+element. On tvOS, that element must already have focus; use `input_button` to
130
+navigate focus before calling `ui_tap`. All three UI tools require the target
131
+app's `bundle_id`; this lets XCTest attach to an already-running app instead of
132
+accidentally reading the ControlKit host app.
133
134
For a local macOS runner, omit simulator fields and pass `host` and
135
`controlkit_port` when needed: