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::{
20 model::{Implementation, ServerCapabilities, ServerConfig},
21 ServerHandler, ServiceExt,
22 },
23 schemars::JsonSchema,
24 serde::{Deserialize, Serialize},
25 serde_json::{json, Value},
26 std::{io::Cursor, path::PathBuf},
27 tokio::io::{
28 stdin, stdout, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader, Stdout,
29 },
30 };
31
32 type StdioReader = Box<dyn AsyncRead + Send + Unpin>;
33
34 /// Preserve compatibility with clients that send the legacy, metadata-free
35 /// `server/discover` probe before falling back to `initialize`.
36 pub async fn compatible_stdio() -> Result<(StdioReader, Stdout)> {
37 let mut reader = BufReader::new(stdin());
38 let mut first_line = String::new();
39 reader.read_line(&mut first_line).await?;
40
41 let mut output = stdout();
42 if let Some(response) = legacy_discovery_response(&first_line) {
43 output.write_all(&response).await?;
44 output.flush().await?;
45 return Ok((Box::new(reader), output));
46 }
47
48 let input = Cursor::new(first_line.into_bytes()).chain(reader);
49 Ok((Box::new(input), output))
50 }
51
52 fn legacy_discovery_response(line: &str) -> Option<Vec<u8>> {
53 let request: Value = serde_json::from_str(line).ok()?;
54 if request.get("method")?.as_str()? != "server/discover"
55 || request.get("params")?.get("_meta").is_some()
56 {
57 return None;
58 }
59
60 let id = request.get("id")?.clone();
61 let mut response = serde_json::to_vec(&json!({
62 "jsonrpc": "2.0",
63 "id": id,
64 "error": {
65 "code": -32601,
66 "message": "Method 'server/discover' is not available without request metadata."
67 }
68 }))
69 .ok()?;
70 response.push(b'\n');
71 Some(response)
72 }
73
74 /// A validated, unambiguous automation target: exactly one of an Apple simulator, an
75 /// Apple ControlKit host (physical device, remote runner, or local Mac), or an
76 /// Android device. Produced by [`resolve_selected_target`], never constructed
77 /// directly from untrusted input.
78 #[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
79 #[serde(tag = "platform", rename_all = "snake_case")]
80 pub enum SelectedTarget {
81 /// An Apple simulator known to `simctl`, identified by name and/or UDID.
82 AppleSimulator {
83 #[serde(default)]
84 simulator_name: Option<String>,
85 #[serde(default)]
86 simulator_udid: Option<String>,
87 /// Local ControlKit JSON-RPC port. Defaults to 12004.
88 #[serde(default)]
89 controlkit_port: Option<u16>,
90 },
91 /// A ControlKit JSON-RPC endpoint reachable over the network: a paired physical
92 /// device, a remote runner, or a local macOS app.
93 AppleHost {
94 host: String,
95 /// Local ControlKit JSON-RPC port. Defaults to 12004.
96 #[serde(default)]
97 controlkit_port: Option<u16>,
98 },
99 /// An Android device or emulator reachable through adb.
100 Android {
101 /// Device serial from `adb devices -l`. `None` means "the single
102 /// authorized device", resolved lazily when the target is used.
103 #[serde(default)]
104 serial: Option<String>,
105 },
106 }
107
108 /// Validate and tag a target from optional per-call fields, falling back to
109 /// `stored` (the target previously selected with `device_select`) when none of the
110 /// fields are provided. Returns a plain error message (rather than an MCP error
111 /// type) so this function stays easy to unit test; callers wrap it for their
112 /// transport.
113 ///
114 /// Rejects ambiguous combinations: `android_serial` together with any Apple field,
115 /// or `simulator_name`/`simulator_udid` together with `host`.
116 pub fn resolve_selected_target(
117 simulator_name: Option<String>,
118 simulator_udid: Option<String>,
119 host: Option<String>,
120 controlkit_port: Option<u16>,
121 android_serial: Option<String>,
122 stored: Option<SelectedTarget>,
123 ) -> Result<SelectedTarget, String> {
124 let apple_simulator_provided = simulator_name.is_some() || simulator_udid.is_some();
125 let apple_host_provided = host.is_some();
126 let android_provided = android_serial.is_some();
127
128 if android_provided && (apple_simulator_provided || apple_host_provided) {
129 return Err(
130 "Ambiguous target: android_serial cannot be combined with simulator_name, \
131 simulator_udid, or host. Provide exactly one target."
132 .to_string(),
133 );
134 }
135 if apple_simulator_provided && apple_host_provided {
136 return Err(
137 "Ambiguous target: simulator_name/simulator_udid cannot be combined with host. \
138 Provide exactly one target."
139 .to_string(),
140 );
141 }
142
143 if let Some(serial) = android_serial {
144 return Ok(SelectedTarget::Android {
145 serial: Some(serial),
146 });
147 }
148 if let Some(host) = host {
149 return Ok(SelectedTarget::AppleHost {
150 host,
151 controlkit_port,
152 });
153 }
154 if apple_simulator_provided {
155 return Ok(SelectedTarget::AppleSimulator {
156 simulator_name,
157 simulator_udid,
158 controlkit_port,
159 });
160 }
161
162 stored.ok_or_else(|| {
163 "No target provided. Pass simulator_name/simulator_udid, host, or android_serial, \
164 or select a default target first with device_select."
165 .to_string()
166 })
167 }
168
169 /// Reject a resolved target that is Android for a tool that only works on Apple
170 /// platforms (UI introspection, orientation, macOS click, visionOS spatial tap).
171 /// Returns a message naming the offending tool so the error is actionable.
172 pub fn require_apple_target(tool_name: &str, target: &SelectedTarget) -> Result<(), String> {
173 match target {
174 SelectedTarget::Android { serial } => Err(format!(
175 "{tool_name} is Apple-only; the active target is an Android device ({}). \
176 Select an Apple simulator or ControlKit host with device_select, or pass \
177 simulator_name, simulator_udid, or host explicitly.",
178 serial.as_deref().unwrap_or("auto-detected")
179 )),
180 SelectedTarget::AppleSimulator { .. } | SelectedTarget::AppleHost { .. } => Ok(()),
181 }
182 }
183
184 /// Round a floating-point coordinate to the integer pixel value adb's `input`
185 /// commands require, rejecting non-finite values instead of silently truncating.
186 pub fn to_android_coordinate(value: f64) -> Result<i32, String> {
187 if !value.is_finite() {
188 return Err(format!("coordinate {value} is not a finite number"));
189 }
190 Ok(value.round() as i32)
191 }
192
193 /// tvOS/iOS/watchOS remote and hardware buttons reachable through ControlKit's
194 /// `device.io.button` method.
195 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
196 pub enum AppleButton {
197 Up,
198 Down,
199 Left,
200 Right,
201 Select,
202 Menu,
203 Home,
204 PlayPause,
205 }
206
207 impl AppleButton {
208 pub const ALL: [&'static str; 8] = [
209 "up",
210 "down",
211 "left",
212 "right",
213 "select",
214 "menu",
215 "home",
216 "playPause",
217 ];
218
219 pub fn parse(value: &str) -> Option<Self> {
220 match value {
221 "up" => Some(Self::Up),
222 "down" => Some(Self::Down),
223 "left" => Some(Self::Left),
224 "right" => Some(Self::Right),
225 "select" => Some(Self::Select),
226 "menu" => Some(Self::Menu),
227 "home" => Some(Self::Home),
228 "playPause" => Some(Self::PlayPause),
229 _ => None,
230 }
231 }
232
233 pub fn as_str(self) -> &'static str {
234 match self {
235 Self::Up => "up",
236 Self::Down => "down",
237 Self::Left => "left",
238 Self::Right => "right",
239 Self::Select => "select",
240 Self::Menu => "menu",
241 Self::Home => "home",
242 Self::PlayPause => "playPause",
243 }
244 }
245 }
246
247 /// Android system buttons reachable through `adb shell input keyevent`.
248 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
249 pub enum AndroidButton {
250 Home,
251 Back,
252 Enter,
253 Recents,
254 }
255
256 impl AndroidButton {
257 pub const ALL: [&'static str; 4] = ["home", "back", "enter", "recents"];
258
259 pub fn parse(value: &str) -> Option<Self> {
260 match value {
261 "home" => Some(Self::Home),
262 "back" => Some(Self::Back),
263 "enter" => Some(Self::Enter),
264 "recents" => Some(Self::Recents),
265 _ => None,
266 }
267 }
268
269 pub fn as_str(self) -> &'static str {
270 match self {
271 Self::Home => "home",
272 Self::Back => "back",
273 Self::Enter => "enter",
274 Self::Recents => "recents",
275 }
276 }
277 }
278
279 /// The button-name vocabulary accepted by `input_button`, spanning both
280 /// platforms so the tool has a single typed, enumerable field instead of a free
281 /// string. Which subset is actually valid depends on the resolved target's
282 /// platform: Apple targets accept `up`, `down`, `left`, `right`, `select`,
283 /// `menu`, `home`, or `playPause`; Android targets accept `home`, `back`,
284 /// `enter`, or `recents`. `home` is valid on both. Passing a value from the
285 /// wrong platform's vocabulary for the resolved target is rejected at call time
286 /// with an error naming the valid set for that platform.
287 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
288 #[serde(rename_all = "camelCase")]
289 pub enum ButtonName {
290 Up,
291 Down,
292 Left,
293 Right,
294 Select,
295 Menu,
296 Home,
297 PlayPause,
298 Back,
299 Enter,
300 Recents,
301 }
302
303 impl ButtonName {
304 pub fn as_str(self) -> &'static str {
305 match self {
306 Self::Up => "up",
307 Self::Down => "down",
308 Self::Left => "left",
309 Self::Right => "right",
310 Self::Select => "select",
311 Self::Menu => "menu",
312 Self::Home => "home",
313 Self::PlayPause => "playPause",
314 Self::Back => "back",
315 Self::Enter => "enter",
316 Self::Recents => "recents",
317 }
318 }
319 }
320
321 /// Screen orientation accepted by `orientation_set`. Apple-only: ControlKit is the
322 /// only backend that implements orientation control today.
323 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
324 #[serde(rename_all = "UPPERCASE")]
325 pub enum Orientation {
326 Portrait,
327 Landscape,
328 }
329
330 impl Orientation {
331 pub fn as_str(self) -> &'static str {
332 match self {
333 Self::Portrait => "PORTRAIT",
334 Self::Landscape => "LANDSCAPE",
335 }
336 }
337 }
338
339 /// Static, hand-maintained capability description for Android targets. Unlike
340 /// Apple's `device.capabilities` ControlKit call, adb has no capability-discovery
341 /// RPC, so this mirrors exactly the actions `AndroidDebugBridge` implements.
342 pub fn android_capabilities() -> serde_json::Value {
343 serde_json::json!({
344 "screen_capture": true,
345 "app_launch": {
346 "supported": true,
347 "note": "Resolves the Leanback (Android TV) launcher activity first, \
348 then falls back to the standard launcher activity.",
349 },
350 "app_terminate": true,
351 "url_open": true,
352 "input_tap": true,
353 "input_text": true,
354 "input_swipe": true,
355 "input_button": {
356 "supported": true,
357 "buttons": AndroidButton::ALL,
358 },
359 "app_install_launch": false,
360 "ui_describe": false,
361 "ui_element_list": false,
362 "ui_tap": false,
363 "orientation_get": false,
364 "orientation_set": false,
365 "input_click": false,
366 "input_spatial_tap": false,
367 })
368 }
369
370 /// Target-only arguments shared by `device_select` and `device_capabilities`.
371 #[derive(Debug, Deserialize, JsonSchema)]
372 pub struct DeviceSelectArgs {
373 /// Exact Apple simulator name to remember as the active target. Mutually
374 /// exclusive with `host` and `android_serial`.
375 #[serde(default)]
376 pub simulator_name: Option<String>,
377 /// Apple simulator UDID to remember as the active target. Mutually exclusive
378 /// with `host` and `android_serial`.
379 #[serde(default)]
380 pub simulator_udid: Option<String>,
381 /// ControlKit host of a physical device, remote runner, or local Mac to
382 /// remember. Mutually exclusive with `simulator_name`/`simulator_udid` and
383 /// `android_serial`.
384 #[serde(default)]
385 pub host: Option<String>,
386 /// Local ControlKit JSON-RPC port. Defaults to 12004. Only meaningful with
387 /// `simulator_name`/`simulator_udid` or `host`.
388 #[serde(default)]
389 pub controlkit_port: Option<u16>,
390 /// Android device serial from `adb devices -l` to remember as the active
391 /// target. Mutually exclusive with every Apple field.
392 #[serde(default)]
393 pub android_serial: Option<String>,
394 }
395
396 /// Structured result of `device_select`: the single unambiguous target that is
397 /// now remembered for later calls, until overridden by another `device_select`
398 /// call or by explicit target fields on a later call.
399 #[derive(Debug, Clone, Serialize, JsonSchema)]
400 pub struct DeviceSelectOutput {
401 pub selected: SelectedTarget,
402 }
403
404 #[derive(Debug, Deserialize, JsonSchema)]
405 pub struct DeviceCapabilitiesArgs {
406 /// Exact Apple simulator name. Omit to use the target selected with
407 /// `device_select`.
408 #[serde(default)]
409 pub simulator_name: Option<String>,
410 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
411 #[serde(default)]
412 pub simulator_udid: Option<String>,
413 /// ControlKit host of a physical device, remote runner, or local Mac.
414 #[serde(default)]
415 pub host: Option<String>,
416 /// Local ControlKit JSON-RPC port. Defaults to 12004.
417 #[serde(default)]
418 pub controlkit_port: Option<u16>,
419 /// Android device serial from `adb devices -l`.
420 #[serde(default)]
421 pub android_serial: Option<String>,
422 }
423
424 #[derive(Debug, Deserialize, JsonSchema)]
425 pub struct AppInstallLaunchArgs {
426 /// Exact simulator name to use. Provide this or `simulator_udid`.
427 #[serde(default)]
428 pub simulator_name: Option<String>,
429 /// Simulator UDID to use. Provide this or `simulator_name`.
430 #[serde(default)]
431 pub simulator_udid: Option<String>,
432 /// Path to the `.app` bundle on disk.
433 pub app_path: PathBuf,
434 /// Bundle identifier of the app inside `app_path`.
435 pub bundle_id: String,
436 /// Terminate a previously running instance of the app before launching it.
437 #[serde(default)]
438 pub terminate_before_launch: bool,
439 }
440
441 #[derive(Debug, Deserialize, JsonSchema)]
442 pub struct ScreenCaptureArgs {
443 /// Exact Apple simulator name. Omit to use the target selected with
444 /// `device_select`.
445 #[serde(default)]
446 pub simulator_name: Option<String>,
447 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
448 #[serde(default)]
449 pub simulator_udid: Option<String>,
450 /// ControlKit host. Screen capture is not implemented for ControlKit hosts;
451 /// use `device` instead for a physical device, or select a simulator.
452 #[serde(default)]
453 pub host: Option<String>,
454 /// Local ControlKit JSON-RPC port. Defaults to 12004.
455 #[serde(default)]
456 pub controlkit_port: Option<u16>,
457 /// Physical device identifier known to CoreDevice (UDID, ECID, serial number,
458 /// user-provided name, or DNS name). Provide this to capture a paired physical
459 /// Apple device instead of a simulator. Mutually exclusive with every other
460 /// field.
461 #[serde(default)]
462 pub device: Option<String>,
463 /// Android device serial from `adb devices -l`.
464 #[serde(default)]
465 pub android_serial: Option<String>,
466 }
467
468 #[derive(Debug, Deserialize, JsonSchema)]
469 pub struct AppTargetArgs {
470 /// Exact Apple simulator name. Omit to use the target selected with
471 /// `device_select`.
472 #[serde(default)]
473 pub simulator_name: Option<String>,
474 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
475 #[serde(default)]
476 pub simulator_udid: Option<String>,
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>,
483 /// Android device serial from `adb devices -l`.
484 #[serde(default)]
485 pub android_serial: Option<String>,
486 /// App identifier: an Apple bundle identifier, or an Android package name,
487 /// depending on the resolved target platform.
488 pub app_id: String,
489 }
490
491 #[derive(Debug, Deserialize, JsonSchema)]
492 pub struct UrlOpenArgs {
493 /// Exact Apple simulator name. Omit to use the target selected with
494 /// `device_select`.
495 #[serde(default)]
496 pub simulator_name: Option<String>,
497 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
498 #[serde(default)]
499 pub simulator_udid: Option<String>,
500 /// ControlKit host. Opening URLs is not implemented for ControlKit hosts;
501 /// use a simulator or an Android target instead.
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>,
507 /// Android device serial from `adb devices -l`.
508 #[serde(default)]
509 pub android_serial: Option<String>,
510 /// HTTP(S) URL or custom URL scheme to open.
511 pub url: String,
512 }
513
514 #[derive(Debug, Deserialize, JsonSchema)]
515 pub struct TapArgs {
516 /// Exact Apple simulator name. Omit to use the target selected with
517 /// `device_select`.
518 #[serde(default)]
519 pub simulator_name: Option<String>,
520 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
521 #[serde(default)]
522 pub simulator_udid: Option<String>,
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 /// Horizontal screen coordinate in points (Apple) or pixels (Android).
533 pub x: f64,
534 /// Vertical screen coordinate in points (Apple) or pixels (Android).
535 pub y: f64,
536 }
537
538 #[derive(Debug, Deserialize, JsonSchema)]
539 pub struct TextArgs {
540 /// Exact Apple simulator name. Omit to use the target selected with
541 /// `device_select`.
542 #[serde(default)]
543 pub simulator_name: Option<String>,
544 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
545 #[serde(default)]
546 pub simulator_udid: Option<String>,
547 /// ControlKit host of a physical device or remote runner.
548 #[serde(default)]
549 pub host: Option<String>,
550 /// Local ControlKit JSON-RPC port. Defaults to 12004.
551 #[serde(default)]
552 pub controlkit_port: Option<u16>,
553 /// Android device serial from `adb devices -l`.
554 #[serde(default)]
555 pub android_serial: Option<String>,
556 /// Single-line text to type into the focused field.
557 pub text: String,
558 }
559
560 #[derive(Debug, Deserialize, JsonSchema)]
561 pub struct SwipeArgs {
562 /// Exact Apple simulator name. Omit to use the target selected with
563 /// `device_select`.
564 #[serde(default)]
565 pub simulator_name: Option<String>,
566 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
567 #[serde(default)]
568 pub simulator_udid: Option<String>,
569 /// ControlKit host of a physical device or remote runner.
570 #[serde(default)]
571 pub host: Option<String>,
572 /// Local ControlKit JSON-RPC port. Defaults to 12004.
573 #[serde(default)]
574 pub controlkit_port: Option<u16>,
575 /// Android device serial from `adb devices -l`.
576 #[serde(default)]
577 pub android_serial: Option<String>,
578 /// Swipe start horizontal coordinate.
579 pub x1: f64,
580 /// Swipe start vertical coordinate.
581 pub y1: f64,
582 /// Swipe end horizontal coordinate.
583 pub x2: f64,
584 /// Swipe end vertical coordinate.
585 pub y2: f64,
586 /// Swipe duration in milliseconds. Defaults to 300. Android only; Apple
587 /// ControlKit swipes ignore this field.
588 #[serde(default)]
589 pub duration_ms: Option<u32>,
590 }
591
592 #[derive(Debug, Deserialize, JsonSchema)]
593 pub struct ButtonArgs {
594 /// Exact Apple simulator name. Omit to use the target selected with
595 /// `device_select`.
596 #[serde(default)]
597 pub simulator_name: Option<String>,
598 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
599 #[serde(default)]
600 pub simulator_udid: Option<String>,
601 /// ControlKit host of a physical device or remote runner.
602 #[serde(default)]
603 pub host: Option<String>,
604 /// Local ControlKit JSON-RPC port. Defaults to 12004.
605 #[serde(default)]
606 pub controlkit_port: Option<u16>,
607 /// Android device serial from `adb devices -l`.
608 #[serde(default)]
609 pub android_serial: Option<String>,
610 /// Button to press. For Apple targets: up, down, left, right, select, menu,
611 /// home, or playPause. For Android targets: home, back, enter, or recents.
612 /// Passing a value from the wrong platform's vocabulary is rejected with a
613 /// clear error naming the valid set for the resolved target.
614 pub button: ButtonName,
615 }
616
617 #[derive(Debug, Deserialize, JsonSchema)]
618 pub struct AppleTargetArgs {
619 /// Exact Apple simulator name. Omit to use the target selected with
620 /// `device_select`.
621 #[serde(default)]
622 pub simulator_name: Option<String>,
623 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
624 #[serde(default)]
625 pub simulator_udid: Option<String>,
626 /// ControlKit host of a physical device or remote runner. Omit for a local
627 /// simulator.
628 #[serde(default)]
629 pub host: Option<String>,
630 /// Local ControlKit JSON-RPC port. Defaults to 12004.
631 #[serde(default)]
632 pub controlkit_port: Option<u16>,
633 }
634
635 #[derive(Debug, Deserialize, JsonSchema)]
636 pub struct UiTargetArgs {
637 /// Exact Apple simulator name. Omit to use the target selected with
638 /// `device_select`.
639 #[serde(default)]
640 pub simulator_name: Option<String>,
641 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
642 #[serde(default)]
643 pub simulator_udid: Option<String>,
644 /// ControlKit host of a physical device or remote runner. Omit for a local
645 /// simulator.
646 #[serde(default)]
647 pub host: Option<String>,
648 /// Local ControlKit JSON-RPC port. Defaults to 12004.
649 #[serde(default)]
650 pub controlkit_port: Option<u16>,
651 /// Android device serial from `adb devices -l`.
652 #[serde(default)]
653 pub android_serial: Option<String>,
654 /// Target-neutral app identifier: an Apple bundle identifier or Android package name.
655 #[serde(default)]
656 pub app_id: Option<String>,
657 /// Backward-compatible alias for `app_id` on Apple targets.
658 #[serde(default)]
659 pub bundle_id: Option<String>,
660 }
661
662 #[derive(Debug, Deserialize, JsonSchema)]
663 pub struct UiTapArgs {
664 #[serde(flatten)]
665 pub target: UiTargetArgs,
666 /// Exact accessible text, description, identifier, value, or hint to activate.
667 pub element: String,
668 }
669
670 #[derive(Debug, Deserialize, JsonSchema)]
671 pub struct OrientationSetArgs {
672 /// Exact Apple simulator name. Omit to use the target selected with
673 /// `device_select`.
674 #[serde(default)]
675 pub simulator_name: Option<String>,
676 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
677 #[serde(default)]
678 pub simulator_udid: Option<String>,
679 /// ControlKit host of a physical device or remote runner. Omit for a local
680 /// simulator.
681 #[serde(default)]
682 pub host: Option<String>,
683 /// Local ControlKit JSON-RPC port. Defaults to 12004.
684 #[serde(default)]
685 pub controlkit_port: Option<u16>,
686 /// Desired screen orientation.
687 pub orientation: Orientation,
688 }
689
690 #[derive(Debug, Deserialize, JsonSchema)]
691 pub struct CoordinateArgs {
692 /// Exact Apple simulator name. Omit to use the target selected with
693 /// `device_select`.
694 #[serde(default)]
695 pub simulator_name: Option<String>,
696 /// Apple simulator UDID. Omit to use the target selected with `device_select`.
697 #[serde(default)]
698 pub simulator_udid: Option<String>,
699 /// ControlKit host of a physical device or remote runner. Omit for a local
700 /// simulator or Mac.
701 #[serde(default)]
702 pub host: Option<String>,
703 /// Local ControlKit JSON-RPC port. Defaults to 12004.
704 #[serde(default)]
705 pub controlkit_port: Option<u16>,
706 /// Horizontal screen coordinate.
707 pub x: f32,
708 /// Vertical screen coordinate.
709 pub y: f32,
710 }
711
712 #[derive(Debug, Default)]
713 pub struct XcrsMcpServer;
714
715 impl XcrsMcpServer {
716 pub fn new() -> Self {
717 Self
718 }
719 }
720
721 #[macro_export]
722 macro_rules! xcrs_mcp_tools {
723 (
724 $server:ty,
725 $device_list_name:literal,
726 $device_select_name:literal,
727 $device_capabilities_name:literal,
728 $app_install_launch_name:literal,
729 $screen_capture_name:literal,
730 $app_launch_name:literal,
731 $app_terminate_name:literal,
732 $url_open_name:literal,
733 $ui_describe_name:literal,
734 $ui_element_list_name:literal,
735 $ui_tap_name:literal,
736 $input_tap_name:literal,
737 $input_text_name:literal,
738 $input_swipe_name:literal,
739 $input_button_name:literal,
740 $input_click_name:literal,
741 $input_spatial_tap_name:literal,
742 $orientation_get_name:literal,
743 $orientation_set_name:literal
744 ) => {
745 #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
746 impl $server {
747 fn selected_target_slot(
748 ) -> &'static ::std::sync::Mutex<Option<$crate::mcp::SelectedTarget>> {
749 static SLOT: ::std::sync::Mutex<Option<$crate::mcp::SelectedTarget>> =
750 ::std::sync::Mutex::new(None);
751 &SLOT
752 }
753
754 fn stored_target() -> Option<$crate::mcp::SelectedTarget> {
755 Self::selected_target_slot()
756 .lock()
757 .ok()
758 .and_then(|slot| slot.clone())
759 }
760
761 fn require_app_id(
762 tool_name: &str,
763 app_id: Option<String>,
764 bundle_id: Option<String>,
765 ) -> ::std::result::Result<String, ::rmcp::model::ErrorData> {
766 let app_id = app_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
767 let bundle_id = bundle_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
768 if let (Some(app_id), Some(bundle_id)) = (app_id, bundle_id) {
769 if app_id != bundle_id {
770 return Err(::rmcp::model::ErrorData::invalid_request(
771 format!("{tool_name} received conflicting app_id and bundle_id values."),
772 None,
773 ));
774 }
775 }
776 app_id.or(bundle_id).map(str::to_string).ok_or_else(|| {
777 ::rmcp::model::ErrorData::invalid_request(
778 format!(
779 "{tool_name} requires app_id. Pass the Apple bundle identifier or Android package name of the foreground app."
780 ),
781 None,
782 )
783 })
784 }
785
786 /// Validate and tag a target from per-call fields, falling back to the
787 /// target selected with `device_select` when no field is provided.
788 fn dispatch_target(
789 simulator_name: Option<String>,
790 simulator_udid: Option<String>,
791 host: Option<String>,
792 controlkit_port: Option<u16>,
793 android_serial: Option<String>,
794 ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData>
795 {
796 $crate::mcp::resolve_selected_target(
797 simulator_name,
798 simulator_udid,
799 host,
800 controlkit_port,
801 android_serial,
802 Self::stored_target(),
803 )
804 .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))
805 }
806
807 /// Like [`Self::dispatch_target`] but for tools that only support Apple
808 /// targets; rejects an Android target (explicit or stored) with a clear
809 /// error naming `tool_name`.
810 fn dispatch_apple_target(
811 tool_name: &str,
812 simulator_name: Option<String>,
813 simulator_udid: Option<String>,
814 host: Option<String>,
815 controlkit_port: Option<u16>,
816 ) -> ::std::result::Result<$crate::mcp::SelectedTarget, ::rmcp::model::ErrorData>
817 {
818 let target =
819 Self::dispatch_target(simulator_name, simulator_udid, host, controlkit_port, None)?;
820 $crate::mcp::require_apple_target(tool_name, &target)
821 .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
822 Ok(target)
823 }
824
825 fn simulator_for(
826 simulator_name: &Option<String>,
827 simulator_udid: &Option<String>,
828 ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> {
829 let tools = $crate::XcodeCommandLineTools::new();
830 match (simulator_udid, simulator_name) {
831 (Some(udid), _) => tools.simctl().find_simulator_by_udid(udid).map_err(
832 |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None),
833 ),
834 (None, Some(name)) => tools.simctl().find_simulator_by_name(name).map_err(
835 |error| ::rmcp::model::ErrorData::invalid_request(error.to_string(), None),
836 ),
837 (None, None) => Err(::rmcp::model::ErrorData::invalid_request(
838 "Apple target requires simulator_name or simulator_udid.",
839 None,
840 )),
841 }
842 }
843
844 /// Build the ControlKit client and, when applicable, resolve the
845 /// simulator for an Apple target. Callers must not pass an Android
846 /// target; use `dispatch_apple_target` or branch on the target first.
847 fn controlkit_for_target(
848 target: &$crate::mcp::SelectedTarget,
849 ) -> ::std::result::Result<
850 (Option<$crate::Simulator>, $crate::ControlKit),
851 ::rmcp::model::ErrorData,
852 > {
853 match target {
854 $crate::mcp::SelectedTarget::AppleHost {
855 host,
856 controlkit_port,
857 } => Ok((
858 None,
859 $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)),
860 )),
861 $crate::mcp::SelectedTarget::AppleSimulator {
862 simulator_name,
863 simulator_udid,
864 controlkit_port,
865 } => {
866 let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
867 Ok((
868 Some(simulator),
869 $crate::ControlKit::new(controlkit_port.unwrap_or(12004)),
870 ))
871 }
872 $crate::mcp::SelectedTarget::Android { .. } => {
873 Err(::rmcp::model::ErrorData::invalid_request(
874 "Android targets do not use ControlKit.",
875 None,
876 ))
877 }
878 }
879 }
880
881 async fn run_android_blocking<T, F>(
882 operation: F,
883 ) -> ::anyhow::Result<T>
884 where
885 T: Send + 'static,
886 F: FnOnce() -> ::anyhow::Result<T> + Send + 'static,
887 {
888 ::tokio::task::spawn_blocking(operation)
889 .await
890 .map_err(|error| ::anyhow::anyhow!("Android adb task failed: {error}"))?
891 }
892
893 async fn android_device_for(
894 serial: Option<String>,
895 ) -> ::std::result::Result<$crate::AndroidDevice, ::rmcp::model::ErrorData> {
896 Self::run_android_blocking(move || {
897 $crate::AndroidDebugBridge::new().resolve_device(serial.as_deref())
898 })
899 .await
900 .map_err(|error| {
901 ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
902 })
903 }
904
905 fn target_label(
906 target: &$crate::mcp::SelectedTarget,
907 simulator: &Option<$crate::Simulator>,
908 ) -> String {
909 match target {
910 $crate::mcp::SelectedTarget::Android { serial } => serial
911 .clone()
912 .unwrap_or_else(|| "the connected Android device".to_string()),
913 $crate::mcp::SelectedTarget::AppleHost { host, .. } => host.clone(),
914 $crate::mcp::SelectedTarget::AppleSimulator { .. } => simulator
915 .as_ref()
916 .map(|simulator| simulator.name.clone())
917 .unwrap_or_else(|| "the selected simulator".to_string()),
918 }
919 }
920
921 #[::rmcp::tool(
922 name = $device_list_name,
923 title = "List devices",
924 annotations(title = "List devices", read_only_hint = true, idempotent_hint = true),
925 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."
926 )]
927 async fn device_list(
928 &self,
929 ) -> ::std::result::Result<
930 ::rmcp::model::CallToolResult,
931 ::rmcp::model::ErrorData,
932 > {
933 let mut devices = Vec::new();
934 let mut errors = ::serde_json::Map::new();
935
936 match $crate::XcodeCommandLineTools::new().simctl().list_simulators() {
937 Ok(simulators) => devices.extend(
938 simulators
939 .iter()
940 .map($crate::NormalizedDevice::from_simulator),
941 ),
942 Err(error) => {
943 errors.insert("apple".to_string(), ::serde_json::json!(error.to_string()));
944 }
945 }
946
947 match Self::run_android_blocking(|| {
948 $crate::AndroidDebugBridge::new().list_devices()
949 })
950 .await
951 {
952 Ok(android_devices) => devices.extend(
953 android_devices
954 .iter()
955 .map($crate::NormalizedDevice::from_android_device),
956 ),
957 Err(error) => {
958 errors.insert("android".to_string(), ::serde_json::json!(error.to_string()));
959 }
960 }
961
962 Ok(::rmcp::model::CallToolResult::success(vec![
963 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
964 "devices": devices,
965 "errors": errors,
966 }))?,
967 ]))
968 }
969
970 #[::rmcp::tool(
971 name = $device_select_name,
972 title = "Select active device",
973 annotations(title = "Select active device", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
974 output_schema = ::rmcp::handler::server::tool::schema_for_type::<$crate::mcp::DeviceSelectOutput>(),
975 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)."
976 )]
977 async fn device_select(
978 &self,
979 ::rmcp::handler::server::wrapper::Parameters(
980 args,
981 ): ::rmcp::handler::server::wrapper::Parameters<
982 $crate::mcp::DeviceSelectArgs,
983 >,
984 ) -> ::std::result::Result<
985 ::rmcp::model::CallToolResult,
986 ::rmcp::model::ErrorData,
987 > {
988 let target = $crate::mcp::resolve_selected_target(
989 args.simulator_name,
990 args.simulator_udid,
991 args.host,
992 args.controlkit_port,
993 args.android_serial,
994 None,
995 )
996 .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
997 match Self::selected_target_slot().lock() {
998 Ok(mut slot) => *slot = Some(target.clone()),
999 Err(_) => {
1000 return Err(::rmcp::model::ErrorData::internal_error(
1001 "Failed to store the selected target.",
1002 None,
1003 ))
1004 }
1005 }
1006 let output = $crate::mcp::DeviceSelectOutput { selected: target };
1007 let value = ::serde_json::to_value(&output).map_err(|error| {
1008 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1009 })?;
1010 Ok(::rmcp::model::CallToolResult::structured(value))
1011 }
1012
1013 #[::rmcp::tool(
1014 name = $device_capabilities_name,
1015 title = "Get device capabilities",
1016 annotations(title = "Get device capabilities", read_only_hint = true, idempotent_hint = true),
1017 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, starts from the adb actions this crate implements, then probes XCRS AndroidKit and enables semantic UI actions when its instrumentation runner is reachable. 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, and XCRS AndroidKit must be installed and started for semantic UI actions. Failure modes: returns an error if no target can be resolved, or if the ControlKit call fails or times out."
1018 )]
1019 async fn device_capabilities(
1020 &self,
1021 ::rmcp::handler::server::wrapper::Parameters(
1022 args,
1023 ): ::rmcp::handler::server::wrapper::Parameters<
1024 $crate::mcp::DeviceCapabilitiesArgs,
1025 >,
1026 ) -> ::std::result::Result<
1027 ::rmcp::model::CallToolResult,
1028 ::rmcp::model::ErrorData,
1029 > {
1030 let target = Self::dispatch_target(
1031 args.simulator_name,
1032 args.simulator_udid,
1033 args.host,
1034 args.controlkit_port,
1035 args.android_serial,
1036 )?;
1037 match &target {
1038 $crate::mcp::SelectedTarget::Android { serial } => {
1039 let device = Self::android_device_for(serial.clone()).await?;
1040 let mut capabilities = $crate::mcp::android_capabilities();
1041 let androidkit = $crate::AndroidKit::new()
1042 .call(device.serial.clone(), "device.info", ::serde_json::json!({}))
1043 .await;
1044 if let Some(object) = capabilities.as_object_mut() {
1045 match androidkit {
1046 Ok(info) => {
1047 object.insert("ui_describe".to_string(), ::serde_json::json!(true));
1048 object.insert("ui_element_list".to_string(), ::serde_json::json!(true));
1049 object.insert("ui_tap".to_string(), ::serde_json::json!(true));
1050 object.insert("androidkit".to_string(), ::serde_json::json!({ "available": true, "info": info }));
1051 }
1052 Err(error) => {
1053 object.insert("ui_tap".to_string(), ::serde_json::json!(false));
1054 object.insert("androidkit".to_string(), ::serde_json::json!({
1055 "available": false,
1056 "setup": "Install and start XCRS AndroidKit with xcrs android-kit install/start.",
1057 "error": error.to_string(),
1058 }));
1059 }
1060 }
1061 }
1062 Ok(::rmcp::model::CallToolResult::success(vec![
1063 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1064 "platform": "android",
1065 "capabilities": capabilities,
1066 }))?,
1067 ]))
1068 }
1069 _ => {
1070 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1071 let result = controlkit
1072 .call("device.capabilities", ::serde_json::json!({}))
1073 .await
1074 .map_err(|error| {
1075 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1076 })?;
1077 Ok(::rmcp::model::CallToolResult::success(vec![
1078 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1079 "platform": "apple",
1080 "simulator": simulator,
1081 "capabilities": result,
1082 }))?,
1083 ]))
1084 }
1085 }
1086 }
1087
1088 #[::rmcp::tool(
1089 name = $app_install_launch_name,
1090 title = "Install and launch Apple simulator app",
1091 annotations(title = "Install and launch Apple simulator app", read_only_hint = false, destructive_hint = true, idempotent_hint = false),
1092 description = "Purpose: one-shot end-to-end setup for a freshly built Apple-platform app: boot an iOS, tvOS, watchOS, or visionOS 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 selected simulator platform and architecture. Failure modes: errors if neither simulator_name nor simulator_udid is given, if the simulator cannot be found, if the app bundle targets a different platform, or if any underlying `simctl` step fails. Limitations: simulator-only; there is no Android or physical-device equivalent in this tool."
1093 )]
1094 async fn app_install_launch(
1095 &self,
1096 ::rmcp::handler::server::wrapper::Parameters(
1097 args,
1098 ): ::rmcp::handler::server::wrapper::Parameters<
1099 $crate::mcp::AppInstallLaunchArgs,
1100 >,
1101 ) -> ::std::result::Result<
1102 ::rmcp::model::CallToolResult,
1103 ::rmcp::model::ErrorData,
1104 > {
1105 if args.simulator_name.is_none() && args.simulator_udid.is_none() {
1106 return Err(::rmcp::model::ErrorData::invalid_request(
1107 "Provide either simulator_name or simulator_udid.",
1108 None,
1109 ));
1110 }
1111
1112 let result = $crate::XcodeCommandLineTools::new()
1113 .run_ios_app_test(&$crate::IosAppTest {
1114 simulator_name: args.simulator_name,
1115 simulator_udid: args.simulator_udid,
1116 app_path: args.app_path,
1117 bundle_id: args.bundle_id,
1118 terminate_before_launch: args.terminate_before_launch,
1119 })
1120 .map_err(|error| {
1121 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1122 })?;
1123 Ok(::rmcp::model::CallToolResult::success(vec![
1124 ::rmcp::model::ContentBlock::json(&result)?,
1125 ]))
1126 }
1127
1128 #[::rmcp::tool(
1129 name = $screen_capture_name,
1130 title = "Capture screenshot",
1131 annotations(title = "Capture screenshot", read_only_hint = true, idempotent_hint = true),
1132 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."
1133 )]
1134 async fn screen_capture(
1135 &self,
1136 ::rmcp::handler::server::wrapper::Parameters(
1137 args,
1138 ): ::rmcp::handler::server::wrapper::Parameters<
1139 $crate::mcp::ScreenCaptureArgs,
1140 >,
1141 ) -> ::std::result::Result<
1142 ::rmcp::model::CallToolResult,
1143 ::rmcp::model::ErrorData,
1144 > {
1145 if let Some(device) = &args.device {
1146 if args.simulator_name.is_some()
1147 || args.simulator_udid.is_some()
1148 || args.host.is_some()
1149 || args.android_serial.is_some()
1150 {
1151 return Err(::rmcp::model::ErrorData::invalid_request(
1152 "device cannot be combined with simulator_name, simulator_udid, host, or android_serial. Provide exactly one target.",
1153 None,
1154 ));
1155 }
1156 let screenshot = $crate::XcodeCommandLineTools::new()
1157 .devicectl()
1158 .screenshot(device)
1159 .map_err(|error| {
1160 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1161 })?;
1162 return Ok(::rmcp::model::CallToolResult::success(vec![
1163 ::rmcp::model::ContentBlock::image(
1164 $crate::encode_base64(screenshot),
1165 "image/png",
1166 ),
1167 ]));
1168 }
1169
1170 let target = Self::dispatch_target(
1171 args.simulator_name,
1172 args.simulator_udid,
1173 args.host,
1174 args.controlkit_port,
1175 args.android_serial,
1176 )?;
1177 let screenshot = match &target {
1178 $crate::mcp::SelectedTarget::Android { serial } => {
1179 let device = Self::android_device_for(serial.clone()).await?;
1180 let device_serial = device.serial.clone();
1181 Self::run_android_blocking(move || {
1182 $crate::AndroidDebugBridge::new().screenshot(&device_serial)
1183 })
1184 .await
1185 .map_err(|error| {
1186 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1187 })?
1188 }
1189 $crate::mcp::SelectedTarget::AppleSimulator {
1190 simulator_name,
1191 simulator_udid,
1192 ..
1193 } => {
1194 let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1195 $crate::XcodeCommandLineTools::new()
1196 .simctl()
1197 .screenshot(&simulator.udid)
1198 .map_err(|error| {
1199 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1200 })?
1201 }
1202 $crate::mcp::SelectedTarget::AppleHost { host, .. } => {
1203 return Err(::rmcp::model::ErrorData::invalid_request(
1204 format!(
1205 "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."
1206 ),
1207 None,
1208 ));
1209 }
1210 };
1211 Ok(::rmcp::model::CallToolResult::success(vec![
1212 ::rmcp::model::ContentBlock::image(
1213 $crate::encode_base64(screenshot),
1214 "image/png",
1215 ),
1216 ]))
1217 }
1218
1219 #[::rmcp::tool(
1220 name = $app_launch_name,
1221 title = "Launch app",
1222 annotations(title = "Launch app", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1223 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."
1224 )]
1225 async fn app_launch(
1226 &self,
1227 ::rmcp::handler::server::wrapper::Parameters(
1228 args,
1229 ): ::rmcp::handler::server::wrapper::Parameters<
1230 $crate::mcp::AppTargetArgs,
1231 >,
1232 ) -> ::std::result::Result<
1233 ::rmcp::model::CallToolResult,
1234 ::rmcp::model::ErrorData,
1235 > {
1236 let target = Self::dispatch_target(
1237 args.simulator_name,
1238 args.simulator_udid,
1239 args.host,
1240 args.controlkit_port,
1241 args.android_serial,
1242 )?;
1243 let label = match &target {
1244 $crate::mcp::SelectedTarget::Android { serial } => {
1245 let device = Self::android_device_for(serial.clone()).await?;
1246 let device_serial = device.serial.clone();
1247 let app_id = args.app_id.clone();
1248 Self::run_android_blocking(move || {
1249 $crate::AndroidDebugBridge::new()
1250 .launch_app(&device_serial, &app_id)
1251 })
1252 .await
1253 .map_err(|error| {
1254 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1255 })?;
1256 device.serial
1257 }
1258 $crate::mcp::SelectedTarget::AppleHost {
1259 host,
1260 controlkit_port,
1261 } => {
1262 let controlkit =
1263 $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
1264 controlkit
1265 .call(
1266 "device.apps.launch",
1267 ::serde_json::json!({ "bundleId": args.app_id }),
1268 )
1269 .await
1270 .map_err(|error| {
1271 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1272 })?;
1273 host.clone()
1274 }
1275 $crate::mcp::SelectedTarget::AppleSimulator {
1276 simulator_name,
1277 simulator_udid,
1278 ..
1279 } => {
1280 let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1281 $crate::XcodeCommandLineTools::new()
1282 .simctl()
1283 .launch_app(&simulator.udid, &args.app_id)
1284 .map_err(|error| {
1285 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1286 })?;
1287 simulator.name
1288 }
1289 };
1290 Ok(::rmcp::model::CallToolResult::success(vec![
1291 ::rmcp::model::ContentBlock::text(format!(
1292 "Launched {} on {}.",
1293 args.app_id, label
1294 )),
1295 ]))
1296 }
1297
1298 #[::rmcp::tool(
1299 name = $app_terminate_name,
1300 title = "Terminate app",
1301 annotations(title = "Terminate app", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1302 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."
1303 )]
1304 async fn app_terminate(
1305 &self,
1306 ::rmcp::handler::server::wrapper::Parameters(
1307 args,
1308 ): ::rmcp::handler::server::wrapper::Parameters<
1309 $crate::mcp::AppTargetArgs,
1310 >,
1311 ) -> ::std::result::Result<
1312 ::rmcp::model::CallToolResult,
1313 ::rmcp::model::ErrorData,
1314 > {
1315 let target = Self::dispatch_target(
1316 args.simulator_name,
1317 args.simulator_udid,
1318 args.host,
1319 args.controlkit_port,
1320 args.android_serial,
1321 )?;
1322 let label = match &target {
1323 $crate::mcp::SelectedTarget::Android { serial } => {
1324 let device = Self::android_device_for(serial.clone()).await?;
1325 let device_serial = device.serial.clone();
1326 let app_id = args.app_id.clone();
1327 Self::run_android_blocking(move || {
1328 $crate::AndroidDebugBridge::new()
1329 .terminate_app(&device_serial, &app_id)
1330 })
1331 .await
1332 .map_err(|error| {
1333 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1334 })?;
1335 device.serial
1336 }
1337 $crate::mcp::SelectedTarget::AppleHost {
1338 host,
1339 controlkit_port,
1340 } => {
1341 let controlkit =
1342 $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004));
1343 controlkit
1344 .call(
1345 "device.apps.terminate",
1346 ::serde_json::json!({ "bundleId": args.app_id }),
1347 )
1348 .await
1349 .map_err(|error| {
1350 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1351 })?;
1352 host.clone()
1353 }
1354 $crate::mcp::SelectedTarget::AppleSimulator {
1355 simulator_name,
1356 simulator_udid,
1357 ..
1358 } => {
1359 let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1360 $crate::XcodeCommandLineTools::new()
1361 .simctl()
1362 .terminate_app(&simulator.udid, &args.app_id)
1363 .map_err(|error| {
1364 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1365 })?;
1366 simulator.name
1367 }
1368 };
1369 Ok(::rmcp::model::CallToolResult::success(vec![
1370 ::rmcp::model::ContentBlock::text(format!(
1371 "Terminated {} on {}.",
1372 args.app_id, label
1373 )),
1374 ]))
1375 }
1376
1377 #[::rmcp::tool(
1378 name = $url_open_name,
1379 title = "Open URL",
1380 annotations(title = "Open URL", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1381 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)."
1382 )]
1383 async fn url_open(
1384 &self,
1385 ::rmcp::handler::server::wrapper::Parameters(
1386 args,
1387 ): ::rmcp::handler::server::wrapper::Parameters<
1388 $crate::mcp::UrlOpenArgs,
1389 >,
1390 ) -> ::std::result::Result<
1391 ::rmcp::model::CallToolResult,
1392 ::rmcp::model::ErrorData,
1393 > {
1394 let target = Self::dispatch_target(
1395 args.simulator_name,
1396 args.simulator_udid,
1397 args.host,
1398 args.controlkit_port,
1399 args.android_serial,
1400 )?;
1401 let label = match &target {
1402 $crate::mcp::SelectedTarget::Android { serial } => {
1403 let device = Self::android_device_for(serial.clone()).await?;
1404 let device_serial = device.serial.clone();
1405 let url = args.url.clone();
1406 Self::run_android_blocking(move || {
1407 $crate::AndroidDebugBridge::new().open_url(&device_serial, &url)
1408 })
1409 .await
1410 .map_err(|error| {
1411 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1412 })?;
1413 device.serial
1414 }
1415 $crate::mcp::SelectedTarget::AppleSimulator {
1416 simulator_name,
1417 simulator_udid,
1418 ..
1419 } => {
1420 let simulator = Self::simulator_for(simulator_name, simulator_udid)?;
1421 $crate::XcodeCommandLineTools::new()
1422 .simctl()
1423 .open_url(&simulator.udid, &args.url)
1424 .map_err(|error| {
1425 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1426 })?;
1427 simulator.name
1428 }
1429 $crate::mcp::SelectedTarget::AppleHost { host, .. } => {
1430 return Err(::rmcp::model::ErrorData::invalid_request(
1431 format!(
1432 "url_open over a ControlKit host ({host}) is not supported; use a simulator or an Android target instead."
1433 ),
1434 None,
1435 ));
1436 }
1437 };
1438 Ok(::rmcp::model::CallToolResult::success(vec![
1439 ::rmcp::model::ContentBlock::text(format!(
1440 "Opened {} on {}.",
1441 args.url, label
1442 )),
1443 ]))
1444 }
1445
1446 #[::rmcp::tool(
1447 name = $ui_describe_name,
1448 title = "Describe UI",
1449 annotations(title = "Describe UI", read_only_hint = true, idempotent_hint = true),
1450 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: for Apple targets, attaches to app_id (or bundle_id) through ControlKit `device.dump.ui`; for Android targets, attaches to app_id through XCRS AndroidKit. Prerequisites: Apple targets need a reachable ControlKit endpoint built from a version that implements `device.dump.ui`; Android targets need XCRS AndroidKit installed and started; app_id must identify the foreground app. Failure modes: errors if no target can be resolved, if app_id is invalid, if the platform runner is unavailable or outdated, or if the requested app is not foreground."
1451 )]
1452 async fn ui_describe(
1453 &self,
1454 ::rmcp::handler::server::wrapper::Parameters(
1455 args,
1456 ): ::rmcp::handler::server::wrapper::Parameters<
1457 $crate::mcp::UiTargetArgs,
1458 >,
1459 ) -> ::std::result::Result<
1460 ::rmcp::model::CallToolResult,
1461 ::rmcp::model::ErrorData,
1462 > {
1463 let app_id = Self::require_app_id($ui_describe_name, args.app_id, args.bundle_id)?;
1464 let target = Self::dispatch_target(
1465 args.simulator_name,
1466 args.simulator_udid,
1467 args.host,
1468 args.controlkit_port,
1469 args.android_serial,
1470 )?;
1471 let (simulator, result) = match &target {
1472 $crate::mcp::SelectedTarget::Android { serial } => {
1473 let device = Self::android_device_for(serial.clone()).await?;
1474 let ui = $crate::AndroidKit::new()
1475 .call(device.serial.clone(), "device.dump.ui", ::serde_json::json!({ "appId": app_id }))
1476 .await
1477 .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1478 (None, ui)
1479 }
1480 _ => {
1481 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1482 let ui = controlkit.call("device.dump.ui", ::serde_json::json!({
1483 "format": "json", "bundleId": app_id,
1484 })).await.map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1485 (simulator, ui)
1486 }
1487 };
1488 Ok(::rmcp::model::CallToolResult::success(vec![
1489 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1490 "simulator": simulator,
1491 "target": target,
1492 "ui": result,
1493 }))?,
1494 ]))
1495 }
1496
1497 #[::rmcp::tool(
1498 name = $ui_element_list_name,
1499 title = "List UI elements",
1500 annotations(title = "List UI elements", read_only_hint = true, idempotent_hint = true),
1501 description = "Purpose: list just the actionable accessibility elements of the foreground app with 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: for Apple targets, attaches to app_id (or bundle_id) through ControlKit `device.dump.ui`; for Android targets, attaches to app_id through XCRS AndroidKit; then filters to elements that have both a visible rect and an identifying label/name/value/rawIdentifier. Prerequisites: Apple targets need a reachable ControlKit endpoint built from a version that implements `device.dump.ui`; Android targets need XCRS AndroidKit installed and started; app_id must identify the foreground app. Failure modes: errors if no target can be resolved, if app_id is invalid, if the platform runner is unavailable or outdated, or if the requested app is not foreground."
1502 )]
1503 async fn ui_element_list(
1504 &self,
1505 ::rmcp::handler::server::wrapper::Parameters(
1506 args,
1507 ): ::rmcp::handler::server::wrapper::Parameters<
1508 $crate::mcp::UiTargetArgs,
1509 >,
1510 ) -> ::std::result::Result<
1511 ::rmcp::model::CallToolResult,
1512 ::rmcp::model::ErrorData,
1513 > {
1514 let app_id = Self::require_app_id($ui_element_list_name, args.app_id, args.bundle_id)?;
1515 let target = Self::dispatch_target(
1516 args.simulator_name, args.simulator_udid, args.host, args.controlkit_port, args.android_serial,
1517 )?;
1518 let (simulator, elements) = match &target {
1519 $crate::mcp::SelectedTarget::Android { serial } => {
1520 let device = Self::android_device_for(serial.clone()).await?;
1521 let ui = $crate::AndroidKit::new().call(
1522 device.serial.clone(), "device.dump.ui", ::serde_json::json!({ "appId": app_id }),
1523 ).await.map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1524 (None, $crate::extract_androidkit_elements(&ui))
1525 }
1526 _ => {
1527 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1528 let ui = controlkit.call("device.dump.ui", ::serde_json::json!({
1529 "format": "json", "bundleId": app_id,
1530 })).await.map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1531 (simulator, $crate::extract_controlkit_elements(&ui))
1532 }
1533 };
1534 Ok(::rmcp::model::CallToolResult::success(vec![
1535 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1536 "simulator": simulator,
1537 "target": target,
1538 "elements": elements,
1539 }))?,
1540 ]))
1541 }
1542
1543 #[::rmcp::tool(
1544 name = $ui_tap_name,
1545 title = "Tap UI element",
1546 annotations(title = "Tap UI element", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1547 description = "Purpose: activate one uniquely identified accessible element in the foreground app. When to use vs siblings: use this for semantic interaction by label or identifier; use ui_describe or ui_element_list when you first need to inspect the screen, and input_tap only when you deliberately need raw screen coordinates. Behavior: accepts app_id (or Apple-compatible bundle_id) and an exact element value, and requires exactly one match. Apple targets go through ControlKit `device.ui.tap`, which attaches to the foreground app and activates the match with the platform interaction API; on tvOS the match must already have focus and the runner presses Select. Android targets use XCRS AndroidKit over adb forwarding. Prerequisites: the app is foreground and its platform runner is installed and reachable. Failure modes: returns an error when the app is not foreground, AndroidKit/ControlKit is unavailable or outdated, zero or multiple elements match, or a tvOS element is not focused."
1548 )]
1549 async fn ui_tap(
1550 &self,
1551 ::rmcp::handler::server::wrapper::Parameters(args): ::rmcp::handler::server::wrapper::Parameters<$crate::mcp::UiTapArgs>,
1552 ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
1553 let app_id = Self::require_app_id($ui_tap_name, args.target.app_id, args.target.bundle_id)?;
1554 let element = args.element.trim().to_string();
1555 if element.is_empty() {
1556 return Err(::rmcp::model::ErrorData::invalid_request(
1557 format!("{} requires a non-empty element.", $ui_tap_name),
1558 None,
1559 ));
1560 }
1561 let target = Self::dispatch_target(
1562 args.target.simulator_name,
1563 args.target.simulator_udid,
1564 args.target.host,
1565 args.target.controlkit_port,
1566 args.target.android_serial,
1567 )?;
1568 let label = match &target {
1569 $crate::mcp::SelectedTarget::Android { serial } => {
1570 let device = Self::android_device_for(serial.clone()).await?;
1571 $crate::AndroidKit::new().call(
1572 device.serial.clone(), "device.ui.tap", ::serde_json::json!({ "appId": app_id, "element": element }),
1573 ).await.map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1574 device.serial
1575 }
1576 _ => {
1577 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1578 controlkit.call("device.ui.tap", ::serde_json::json!({ "bundleId": app_id, "element": element }))
1579 .await
1580 .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
1581 Self::target_label(&target, &simulator)
1582 }
1583 };
1584 Ok(::rmcp::model::CallToolResult::success(vec![
1585 ::rmcp::model::ContentBlock::text(format!("Tapped '{}' on {}.", element, label)),
1586 ]))
1587 }
1588
1589 #[::rmcp::tool(
1590 name = $input_tap_name,
1591 title = "Tap (iOS/tvOS/Android)",
1592 annotations(title = "Tap (iOS/tvOS/Android)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1593 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."
1594 )]
1595 async fn input_tap(
1596 &self,
1597 ::rmcp::handler::server::wrapper::Parameters(
1598 args,
1599 ): ::rmcp::handler::server::wrapper::Parameters<
1600 $crate::mcp::TapArgs,
1601 >,
1602 ) -> ::std::result::Result<
1603 ::rmcp::model::CallToolResult,
1604 ::rmcp::model::ErrorData,
1605 > {
1606 let target = Self::dispatch_target(
1607 args.simulator_name,
1608 args.simulator_udid,
1609 args.host,
1610 args.controlkit_port,
1611 args.android_serial,
1612 )?;
1613 let label = match &target {
1614 $crate::mcp::SelectedTarget::Android { serial } => {
1615 let device = Self::android_device_for(serial.clone()).await?;
1616 let x = $crate::mcp::to_android_coordinate(args.x).map_err(|error| {
1617 ::rmcp::model::ErrorData::invalid_request(error, None)
1618 })?;
1619 let y = $crate::mcp::to_android_coordinate(args.y).map_err(|error| {
1620 ::rmcp::model::ErrorData::invalid_request(error, None)
1621 })?;
1622 let device_serial = device.serial.clone();
1623 Self::run_android_blocking(move || {
1624 $crate::AndroidDebugBridge::new().tap(&device_serial, x, y)
1625 })
1626 .await
1627 .map_err(|error| {
1628 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1629 })?;
1630 device.serial
1631 }
1632 _ => {
1633 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1634 controlkit
1635 .call(
1636 "device.io.tap",
1637 ::serde_json::json!({ "x": args.x, "y": args.y }),
1638 )
1639 .await
1640 .map_err(|error| {
1641 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1642 })?;
1643 Self::target_label(&target, &simulator)
1644 }
1645 };
1646 Ok(::rmcp::model::CallToolResult::success(vec![
1647 ::rmcp::model::ContentBlock::text(format!(
1648 "Tapped ({}, {}) on {}.",
1649 args.x, args.y, label
1650 )),
1651 ]))
1652 }
1653
1654 #[::rmcp::tool(
1655 name = $input_text_name,
1656 title = "Type text",
1657 annotations(title = "Type text", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1658 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`."
1659 )]
1660 async fn input_text(
1661 &self,
1662 ::rmcp::handler::server::wrapper::Parameters(
1663 args,
1664 ): ::rmcp::handler::server::wrapper::Parameters<
1665 $crate::mcp::TextArgs,
1666 >,
1667 ) -> ::std::result::Result<
1668 ::rmcp::model::CallToolResult,
1669 ::rmcp::model::ErrorData,
1670 > {
1671 let target = Self::dispatch_target(
1672 args.simulator_name,
1673 args.simulator_udid,
1674 args.host,
1675 args.controlkit_port,
1676 args.android_serial,
1677 )?;
1678 let label = match &target {
1679 $crate::mcp::SelectedTarget::Android { serial } => {
1680 let device = Self::android_device_for(serial.clone()).await?;
1681 let device_serial = device.serial.clone();
1682 let text = args.text.clone();
1683 Self::run_android_blocking(move || {
1684 $crate::AndroidDebugBridge::new()
1685 .type_text(&device_serial, &text)
1686 })
1687 .await
1688 .map_err(|error| {
1689 ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1690 })?;
1691 device.serial
1692 }
1693 _ => {
1694 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1695 controlkit
1696 .call("device.io.text", ::serde_json::json!({ "text": args.text }))
1697 .await
1698 .map_err(|error| {
1699 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1700 })?;
1701 Self::target_label(&target, &simulator)
1702 }
1703 };
1704 Ok(::rmcp::model::CallToolResult::success(vec![
1705 ::rmcp::model::ContentBlock::text(format!("Typed text on {}.", label)),
1706 ]))
1707 }
1708
1709 #[::rmcp::tool(
1710 name = $input_swipe_name,
1711 title = "Swipe",
1712 annotations(title = "Swipe", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1713 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."
1714 )]
1715 async fn input_swipe(
1716 &self,
1717 ::rmcp::handler::server::wrapper::Parameters(
1718 args,
1719 ): ::rmcp::handler::server::wrapper::Parameters<
1720 $crate::mcp::SwipeArgs,
1721 >,
1722 ) -> ::std::result::Result<
1723 ::rmcp::model::CallToolResult,
1724 ::rmcp::model::ErrorData,
1725 > {
1726 let target = Self::dispatch_target(
1727 args.simulator_name,
1728 args.simulator_udid,
1729 args.host,
1730 args.controlkit_port,
1731 args.android_serial,
1732 )?;
1733 let label = match &target {
1734 $crate::mcp::SelectedTarget::Android { serial } => {
1735 let device = Self::android_device_for(serial.clone()).await?;
1736 let x1 = $crate::mcp::to_android_coordinate(args.x1).map_err(|error| {
1737 ::rmcp::model::ErrorData::invalid_request(error, None)
1738 })?;
1739 let y1 = $crate::mcp::to_android_coordinate(args.y1).map_err(|error| {
1740 ::rmcp::model::ErrorData::invalid_request(error, None)
1741 })?;
1742 let x2 = $crate::mcp::to_android_coordinate(args.x2).map_err(|error| {
1743 ::rmcp::model::ErrorData::invalid_request(error, None)
1744 })?;
1745 let y2 = $crate::mcp::to_android_coordinate(args.y2).map_err(|error| {
1746 ::rmcp::model::ErrorData::invalid_request(error, None)
1747 })?;
1748 let device_serial = device.serial.clone();
1749 let duration_ms = args.duration_ms.unwrap_or(300);
1750 Self::run_android_blocking(move || {
1751 $crate::AndroidDebugBridge::new()
1752 .swipe(&device_serial, x1, y1, x2, y2, duration_ms)
1753 })
1754 .await
1755 .map_err(|error| {
1756 ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1757 })?;
1758 device.serial
1759 }
1760 _ => {
1761 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1762 controlkit
1763 .call(
1764 "device.io.swipe",
1765 ::serde_json::json!({
1766 "x1": args.x1,
1767 "y1": args.y1,
1768 "x2": args.x2,
1769 "y2": args.y2,
1770 }),
1771 )
1772 .await
1773 .map_err(|error| {
1774 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1775 })?;
1776 Self::target_label(&target, &simulator)
1777 }
1778 };
1779 Ok(::rmcp::model::CallToolResult::success(vec![
1780 ::rmcp::model::ContentBlock::text(format!("Swiped on {}.", label)),
1781 ]))
1782 }
1783
1784 #[::rmcp::tool(
1785 name = $input_button_name,
1786 title = "Press button",
1787 annotations(title = "Press button", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1788 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)."
1789 )]
1790 async fn input_button(
1791 &self,
1792 ::rmcp::handler::server::wrapper::Parameters(
1793 args,
1794 ): ::rmcp::handler::server::wrapper::Parameters<
1795 $crate::mcp::ButtonArgs,
1796 >,
1797 ) -> ::std::result::Result<
1798 ::rmcp::model::CallToolResult,
1799 ::rmcp::model::ErrorData,
1800 > {
1801 let target = Self::dispatch_target(
1802 args.simulator_name,
1803 args.simulator_udid,
1804 args.host,
1805 args.controlkit_port,
1806 args.android_serial,
1807 )?;
1808 let label = match &target {
1809 $crate::mcp::SelectedTarget::Android { serial } => {
1810 let button = $crate::mcp::AndroidButton::parse(args.button.as_str()).ok_or_else(|| {
1811 ::rmcp::model::ErrorData::invalid_request(
1812 format!(
1813 "button must be one of {} for an Android target",
1814 $crate::mcp::AndroidButton::ALL.join(", ")
1815 ),
1816 None,
1817 )
1818 })?;
1819 let device = Self::android_device_for(serial.clone()).await?;
1820 let device_serial = device.serial.clone();
1821 Self::run_android_blocking(move || {
1822 $crate::AndroidDebugBridge::new()
1823 .press_button(&device_serial, button.as_str())
1824 })
1825 .await
1826 .map_err(|error| {
1827 ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
1828 })?;
1829 device.serial
1830 }
1831 _ => {
1832 let button = $crate::mcp::AppleButton::parse(args.button.as_str()).ok_or_else(|| {
1833 ::rmcp::model::ErrorData::invalid_request(
1834 format!(
1835 "button must be one of {} for an Apple target",
1836 $crate::mcp::AppleButton::ALL.join(", ")
1837 ),
1838 None,
1839 )
1840 })?;
1841 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1842 controlkit
1843 .call(
1844 "device.io.button",
1845 ::serde_json::json!({ "button": button.as_str() }),
1846 )
1847 .await
1848 .map_err(|error| {
1849 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1850 })?;
1851 Self::target_label(&target, &simulator)
1852 }
1853 };
1854 Ok(::rmcp::model::CallToolResult::success(vec![
1855 ::rmcp::model::ContentBlock::text(format!(
1856 "Pressed {} on {}.",
1857 args.button.as_str(), label
1858 )),
1859 ]))
1860 }
1861
1862 #[::rmcp::tool(
1863 name = $input_click_name,
1864 title = "Click (macOS)",
1865 annotations(title = "Click (macOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1866 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)."
1867 )]
1868 async fn input_click(
1869 &self,
1870 ::rmcp::handler::server::wrapper::Parameters(
1871 args,
1872 ): ::rmcp::handler::server::wrapper::Parameters<
1873 $crate::mcp::CoordinateArgs,
1874 >,
1875 ) -> ::std::result::Result<
1876 ::rmcp::model::CallToolResult,
1877 ::rmcp::model::ErrorData,
1878 > {
1879 let target = Self::dispatch_apple_target(
1880 $input_click_name,
1881 args.simulator_name,
1882 args.simulator_udid,
1883 args.host,
1884 args.controlkit_port,
1885 )?;
1886 let (_, controlkit) = Self::controlkit_for_target(&target)?;
1887 controlkit
1888 .call(
1889 "device.io.click",
1890 ::serde_json::json!({ "x": args.x, "y": args.y }),
1891 )
1892 .await
1893 .map_err(|error| {
1894 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1895 })?;
1896 Ok(::rmcp::model::CallToolResult::success(vec![
1897 ::rmcp::model::ContentBlock::text(format!(
1898 "Clicked ({}, {}).",
1899 args.x, args.y
1900 )),
1901 ]))
1902 }
1903
1904 #[::rmcp::tool(
1905 name = $input_spatial_tap_name,
1906 title = "Spatial tap (visionOS)",
1907 annotations(title = "Spatial tap (visionOS)", read_only_hint = false, destructive_hint = false, idempotent_hint = false),
1908 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)."
1909 )]
1910 async fn input_spatial_tap(
1911 &self,
1912 ::rmcp::handler::server::wrapper::Parameters(
1913 args,
1914 ): ::rmcp::handler::server::wrapper::Parameters<
1915 $crate::mcp::CoordinateArgs,
1916 >,
1917 ) -> ::std::result::Result<
1918 ::rmcp::model::CallToolResult,
1919 ::rmcp::model::ErrorData,
1920 > {
1921 let target = Self::dispatch_apple_target(
1922 $input_spatial_tap_name,
1923 args.simulator_name,
1924 args.simulator_udid,
1925 args.host,
1926 args.controlkit_port,
1927 )?;
1928 let (_, controlkit) = Self::controlkit_for_target(&target)?;
1929 controlkit
1930 .call(
1931 "device.io.spatial.tap",
1932 ::serde_json::json!({ "x": args.x, "y": args.y }),
1933 )
1934 .await
1935 .map_err(|error| {
1936 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1937 })?;
1938 Ok(::rmcp::model::CallToolResult::success(vec![
1939 ::rmcp::model::ContentBlock::text(format!(
1940 "Performed spatial tap at ({}, {}).",
1941 args.x, args.y
1942 )),
1943 ]))
1944 }
1945
1946 #[::rmcp::tool(
1947 name = $orientation_get_name,
1948 title = "Get orientation",
1949 annotations(title = "Get orientation", read_only_hint = true, idempotent_hint = true),
1950 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)."
1951 )]
1952 async fn orientation_get(
1953 &self,
1954 ::rmcp::handler::server::wrapper::Parameters(
1955 args,
1956 ): ::rmcp::handler::server::wrapper::Parameters<
1957 $crate::mcp::AppleTargetArgs,
1958 >,
1959 ) -> ::std::result::Result<
1960 ::rmcp::model::CallToolResult,
1961 ::rmcp::model::ErrorData,
1962 > {
1963 let target = Self::dispatch_apple_target(
1964 $orientation_get_name,
1965 args.simulator_name,
1966 args.simulator_udid,
1967 args.host,
1968 args.controlkit_port,
1969 )?;
1970 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
1971 let result = controlkit
1972 .call("device.io.orientation.get", ::serde_json::json!({}))
1973 .await
1974 .map_err(|error| {
1975 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
1976 })?;
1977 Ok(::rmcp::model::CallToolResult::success(vec![
1978 ::rmcp::model::ContentBlock::json(&::serde_json::json!({
1979 "simulator": simulator,
1980 "orientation": result,
1981 }))?,
1982 ]))
1983 }
1984
1985 #[::rmcp::tool(
1986 name = $orientation_set_name,
1987 title = "Set orientation",
1988 annotations(title = "Set orientation", read_only_hint = false, destructive_hint = false, idempotent_hint = true),
1989 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)."
1990 )]
1991 async fn orientation_set(
1992 &self,
1993 ::rmcp::handler::server::wrapper::Parameters(
1994 args,
1995 ): ::rmcp::handler::server::wrapper::Parameters<
1996 $crate::mcp::OrientationSetArgs,
1997 >,
1998 ) -> ::std::result::Result<
1999 ::rmcp::model::CallToolResult,
2000 ::rmcp::model::ErrorData,
2001 > {
2002 let target = Self::dispatch_apple_target(
2003 $orientation_set_name,
2004 args.simulator_name,
2005 args.simulator_udid,
2006 args.host,
2007 args.controlkit_port,
2008 )?;
2009 let (simulator, controlkit) = Self::controlkit_for_target(&target)?;
2010 controlkit
2011 .call(
2012 "device.io.orientation.set",
2013 ::serde_json::json!({ "orientation": args.orientation.as_str() }),
2014 )
2015 .await
2016 .map_err(|error| {
2017 ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
2018 })?;
2019 Ok(::rmcp::model::CallToolResult::success(vec![
2020 ::rmcp::model::ContentBlock::text(format!(
2021 "Set orientation to {} on {}.",
2022 args.orientation.as_str(),
2023 Self::target_label(&target, &simulator)
2024 )),
2025 ]))
2026 }
2027 }
2028 };
2029 }
2030
2031 xcrs_mcp_tools!(
2032 XcrsMcpServer,
2033 "device_list",
2034 "device_select",
2035 "device_capabilities",
2036 "app_install_launch",
2037 "screen_capture",
2038 "app_launch",
2039 "app_terminate",
2040 "url_open",
2041 "ui_describe",
2042 "ui_element_list",
2043 "ui_tap",
2044 "input_tap",
2045 "input_text",
2046 "input_swipe",
2047 "input_button",
2048 "input_click",
2049 "input_spatial_tap",
2050 "orientation_get",
2051 "orientation_set"
2052 );
2053
2054 #[rmcp::tool_handler(router = Self::xcrs_tool_router())]
2055 impl ServerHandler for XcrsMcpServer {
2056 fn get_info(&self) -> ServerConfig {
2057 let mut implementation = Implementation::from_build_env();
2058 implementation.name = "xcrs".to_string();
2059 implementation.version = env!("CARGO_PKG_VERSION").to_string();
2060
2061 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
2062 .with_server_info(implementation)
2063 .with_instructions(
2064 "xcrs exposes a canonical cross-platform automation contract (device_list, \
2065 device_select, device_capabilities, app_install_launch, screen_capture, \
2066 app_launch, app_terminate, url_open, ui_describe, ui_element_list, ui_tap, input_tap, \
2067 input_text, input_swipe, input_button, input_click, input_spatial_tap, \
2068 orientation_get, orientation_set) over Xcode/simctl/devicectl/ControlKit for \
2069 Apple platforms and adb for Android.",
2070 )
2071 }
2072 }
2073
2074 pub async fn serve() -> Result<()> {
2075 let running = XcrsMcpServer::new()
2076 .serve(compatible_stdio().await?)
2077 .await
2078 .map_err(|error| anyhow!("Failed to start xcrs MCP server: {error}"))?;
2079 running
2080 .waiting()
2081 .await
2082 .map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?;
2083 Ok(())
2084 }
2085
2086 #[cfg(test)]
2087 mod tests {
2088 use super::*;
2089
2090 #[test]
2091 fn legacy_discovery_probe_receives_method_not_found() {
2092 let response = legacy_discovery_response(
2093 r#"{"jsonrpc":"2.0","id":0,"method":"server/discover","params":{}}"#,
2094 )
2095 .expect("legacy discovery probe should receive a fallback response");
2096 let response: Value =
2097 serde_json::from_slice(&response).expect("response should be valid JSON");
2098
2099 assert_eq!(response["id"], 0);
2100 assert_eq!(response["error"]["code"], -32601);
2101 }
2102
2103 #[test]
2104 fn modern_discovery_probe_passes_through() {
2105 let response = legacy_discovery_response(
2106 r#"{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}"#,
2107 );
2108
2109 assert!(response.is_none());
2110 }
2111
2112 /// The 19 canonical tool names shared by the standalone and embedded
2113 /// automation servers.
2114 const CANONICAL_TOOL_NAMES: [&str; 19] = [
2115 "device_list",
2116 "device_select",
2117 "device_capabilities",
2118 "app_install_launch",
2119 "screen_capture",
2120 "app_launch",
2121 "app_terminate",
2122 "url_open",
2123 "ui_describe",
2124 "ui_element_list",
2125 "ui_tap",
2126 "input_tap",
2127 "input_text",
2128 "input_swipe",
2129 "input_button",
2130 "input_click",
2131 "input_spatial_tap",
2132 "orientation_get",
2133 "orientation_set",
2134 ];
2135
2136 /// Fully-qualified tool names this crate used before the canonical-tool
2137 /// consolidation (e.g. `xcrs_list_simulators`, `xcrs_use_target`,
2138 /// `xcrs_gesture`). None of these must reappear as a canonical name; a
2139 /// match here means a rename regressed back to pre-consolidation naming.
2140 const SUPERSEDED_TOOL_NAMES: [&str; 19] = [
2141 "xcrs_list_simulators",
2142 "xcrs_find_simulator",
2143 "xcrs_boot_and_install",
2144 "xcrs_screenshot",
2145 "xcrs_launch_app",
2146 "xcrs_terminate_app",
2147 "xcrs_open_url",
2148 "xcrs_describe_ui",
2149 "xcrs_list_elements",
2150 "xcrs_tap",
2151 "xcrs_type_text",
2152 "xcrs_swipe",
2153 "xcrs_button",
2154 "xcrs_capabilities",
2155 "xcrs_click",
2156 "xcrs_gesture",
2157 "xcrs_use_target",
2158 "xcrs_orientation_get",
2159 "xcrs_orientation_set",
2160 ];
2161
2162 /// Tool names whose output is guaranteed to be either image content
2163 /// (`screen_capture`), plain text with no structured payload, or a
2164 /// passthrough of an arbitrary external JSON-RPC result (ControlKit's
2165 /// `device.capabilities`/`device.dump.ui`/`device.io.orientation.get`, or
2166 /// simulator/device metadata types owned outside this module). None of
2167 /// these can be given a truthful, non-generic `output_schema` without
2168 /// either lying about their shape or coupling this module's schema to
2169 /// types it does not own, so they intentionally advertise no
2170 /// `output_schema`. Only `device_select` returns structured content built
2171 /// entirely from types this module owns, so it is the only tool with one.
2172 const TOOLS_WITHOUT_OUTPUT_SCHEMA: [&str; 18] = [
2173 "device_list",
2174 "device_capabilities",
2175 "app_install_launch",
2176 "screen_capture",
2177 "app_launch",
2178 "app_terminate",
2179 "url_open",
2180 "ui_describe",
2181 "ui_element_list",
2182 "ui_tap",
2183 "input_tap",
2184 "input_text",
2185 "input_swipe",
2186 "input_button",
2187 "input_click",
2188 "input_spatial_tap",
2189 "orientation_get",
2190 "orientation_set",
2191 ];
2192
2193 #[test]
2194 fn tool_router_exposes_exactly_the_nineteen_canonical_tools() {
2195 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2196
2197 assert_eq!(
2198 tools.len(),
2199 CANONICAL_TOOL_NAMES.len(),
2200 "expected exactly {} canonical tools, found {}",
2201 CANONICAL_TOOL_NAMES.len(),
2202 tools.len()
2203 );
2204
2205 let mut actual_names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
2206 actual_names.sort_unstable();
2207 let mut expected_names = CANONICAL_TOOL_NAMES;
2208 expected_names.sort_unstable();
2209 assert_eq!(actual_names, expected_names);
2210 }
2211
2212 #[test]
2213 fn tool_router_uses_unprefixed_canonical_names_with_no_legacy_names() {
2214 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2215 for tool in &tools {
2216 assert!(
2217 !tool.name.starts_with("xcrs_") && !tool.name.starts_with("smb_"),
2218 "{} should not repeat a server prefix",
2219 tool.name
2220 );
2221 assert!(
2222 !SUPERSEDED_TOOL_NAMES.contains(&tool.name.as_ref()),
2223 "{} is a superseded pre-consolidation tool name",
2224 tool.name
2225 );
2226 }
2227 }
2228
2229 #[test]
2230 fn every_tool_has_a_meaningful_concise_title() {
2231 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2232 for tool in &tools {
2233 let title = tool
2234 .title
2235 .as_deref()
2236 .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name));
2237 assert!(!title.trim().is_empty(), "{} has a blank title", tool.name);
2238 assert!(
2239 title.len() <= 40,
2240 "{} title should be concise (<= 40 chars), got {title:?}",
2241 tool.name
2242 );
2243 assert_ne!(
2244 title,
2245 tool.name.as_ref(),
2246 "{} title should be a human-readable label, not the raw tool name",
2247 tool.name
2248 );
2249 }
2250 }
2251
2252 #[test]
2253 fn every_tool_has_a_front_loaded_transparent_description() {
2254 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2255 for tool in &tools {
2256 let description = tool
2257 .description
2258 .as_deref()
2259 .unwrap_or_else(|| panic!("{} is missing a description", tool.name));
2260 assert!(
2261 !description.trim().is_empty(),
2262 "{} has a blank description",
2263 tool.name
2264 );
2265 // Purpose Clarity: the purpose must be the first thing a reader sees.
2266 assert!(
2267 description.starts_with("Purpose:"),
2268 "{} description should front-load its purpose",
2269 tool.name
2270 );
2271 // Usage Guidelines: siblings and when-to-use guidance must be present.
2272 assert!(
2273 description.contains("When to use"),
2274 "{} description should say when to use it relative to sibling tools",
2275 tool.name
2276 );
2277 // Behavioral Transparency: behavior, prerequisites, and failure modes.
2278 assert!(
2279 description.contains("Behavior:"),
2280 "{} description should document its behavior",
2281 tool.name
2282 );
2283 assert!(
2284 description.contains("Prerequisites:"),
2285 "{} description should document its prerequisites",
2286 tool.name
2287 );
2288 assert!(
2289 description.contains("Failure modes:"),
2290 "{} description should document its failure modes",
2291 tool.name
2292 );
2293 // Not a snapshot: only bound length loosely, so wording can evolve
2294 // without brittle exact-text assertions.
2295 assert!(
2296 description.len() >= 150,
2297 "{} description is too terse for behavioral transparency ({} chars)",
2298 tool.name,
2299 description.len()
2300 );
2301 }
2302 }
2303
2304 #[test]
2305 fn every_tool_has_internally_consistent_annotations() {
2306 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2307 for tool in &tools {
2308 let annotations = tool
2309 .annotations
2310 .as_ref()
2311 .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
2312 let annotations_title = annotations
2313 .title
2314 .as_deref()
2315 .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name));
2316 assert_eq!(
2317 Some(annotations_title),
2318 tool.title.as_deref(),
2319 "{} top-level title and annotations title must agree",
2320 tool.name
2321 );
2322 // A tool cannot simultaneously claim to be read-only and to
2323 // perform destructive updates.
2324 assert!(
2325 !(annotations.read_only_hint == Some(true)
2326 && annotations.destructive_hint == Some(true)),
2327 "{} annotations contradict: read_only_hint and destructive_hint are both true",
2328 tool.name
2329 );
2330 }
2331 }
2332
2333 #[test]
2334 fn every_input_schema_property_has_a_description() {
2335 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2336 for tool in &tools {
2337 let Some(properties) = tool
2338 .input_schema
2339 .get("properties")
2340 .and_then(|value| value.as_object())
2341 else {
2342 continue;
2343 };
2344 for (property_name, property_schema) in properties {
2345 let description = property_schema
2346 .get("description")
2347 .and_then(|value| value.as_str())
2348 .unwrap_or_default();
2349 assert!(
2350 !description.trim().is_empty(),
2351 "{}.{} is missing a parameter description",
2352 tool.name,
2353 property_name
2354 );
2355 }
2356 }
2357 }
2358
2359 #[test]
2360 fn ui_tools_keep_app_identifiers_optional_in_the_input_schema() {
2361 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2362 for tool_name in ["ui_describe", "ui_element_list", "ui_tap"] {
2363 let tool = tools
2364 .iter()
2365 .find(|tool| tool.name.as_ref() == tool_name)
2366 .unwrap_or_else(|| panic!("{tool_name} should be registered"));
2367 let required = tool
2368 .input_schema
2369 .get("required")
2370 .and_then(serde_json::Value::as_array)
2371 .cloned()
2372 .unwrap_or_default();
2373
2374 assert!(tool.input_schema["properties"].get("bundle_id").is_some());
2375 assert!(tool.input_schema["properties"].get("app_id").is_some());
2376 assert!(!required.contains(&serde_json::json!("bundle_id")));
2377 assert!(!required.contains(&serde_json::json!("app_id")));
2378 }
2379 }
2380
2381 #[test]
2382 fn orientation_get_does_not_advertise_bundle_id() {
2383 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2384 let tool = tools
2385 .iter()
2386 .find(|tool| tool.name.as_ref() == "orientation_get")
2387 .expect("orientation_get should be registered");
2388 let properties = tool.input_schema["properties"]
2389 .as_object()
2390 .expect("orientation_get properties should be an object");
2391
2392 for property in [
2393 "simulator_name",
2394 "simulator_udid",
2395 "host",
2396 "controlkit_port",
2397 ] {
2398 assert!(
2399 properties.contains_key(property),
2400 "orientation_get should advertise {property}"
2401 );
2402 }
2403 assert!(!properties.contains_key("bundle_id"));
2404 }
2405
2406 #[test]
2407 fn ui_target_args_accept_omitted_app_id_and_validate_it_explicitly() {
2408 let args: UiTargetArgs =
2409 serde_json::from_value(serde_json::json!({})).expect("arguments should deserialize");
2410
2411 assert!(args.bundle_id.is_none());
2412 let error = XcrsMcpServer::require_app_id("ui_describe", args.app_id, args.bundle_id)
2413 .expect_err("missing app_id should fail validation");
2414 assert!(error.to_string().contains("ui_describe requires app_id"));
2415 assert_eq!(
2416 XcrsMcpServer::require_app_id(
2417 "ui_describe",
2418 Some(" com.example.app ".to_string()),
2419 Some(" com.example.app ".to_string())
2420 )
2421 .expect("matching app identifiers should pass validation"),
2422 "com.example.app"
2423 );
2424 }
2425
2426 #[test]
2427 fn output_schema_is_present_only_where_structured_content_is_guaranteed() {
2428 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2429 assert_eq!(
2430 tools.len(),
2431 TOOLS_WITHOUT_OUTPUT_SCHEMA.len() + 1,
2432 "update TOOLS_WITHOUT_OUTPUT_SCHEMA if the canonical tool set changes"
2433 );
2434 for tool in &tools {
2435 if tool.name.as_ref() == "device_select" {
2436 assert!(
2437 tool.output_schema.is_some(),
2438 "device_select should advertise an output_schema for its fully-owned SelectedTarget payload"
2439 );
2440 } else {
2441 assert!(
2442 TOOLS_WITHOUT_OUTPUT_SCHEMA.contains(&tool.name.as_ref()),
2443 "{} has an output_schema but is not in the documented intentional-absence list",
2444 tool.name
2445 );
2446 assert!(
2447 tool.output_schema.is_none(),
2448 "{} should not advertise an output_schema (image, text-only, or externally-owned/passthrough content)",
2449 tool.name
2450 );
2451 }
2452 }
2453 }
2454
2455 #[tokio::test]
2456 async fn device_select_structured_content_matches_its_advertised_output_schema() {
2457 let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2458 let device_select_tool = tools
2459 .iter()
2460 .find(|tool| tool.name.as_ref() == "device_select")
2461 .expect("device_select tool should be registered");
2462 let output_schema = device_select_tool
2463 .output_schema
2464 .as_ref()
2465 .expect("device_select should have an output_schema");
2466 let required_properties: Vec<&str> = output_schema
2467 .get("required")
2468 .and_then(|value| value.as_array())
2469 .map(|values| values.iter().filter_map(|value| value.as_str()).collect())
2470 .unwrap_or_default();
2471
2472 let result = XcrsMcpServer::new()
2473 .device_select(::rmcp::handler::server::wrapper::Parameters(
2474 DeviceSelectArgs {
2475 simulator_name: Some("iPhone 16".to_string()),
2476 simulator_udid: None,
2477 host: None,
2478 controlkit_port: None,
2479 android_serial: None,
2480 },
2481 ))
2482 .await
2483 .expect("device_select should succeed with an unambiguous simulator target");
2484
2485 let structured_content = result
2486 .structured_content
2487 .expect("device_select should populate structured_content matching its output_schema");
2488 let structured_object = structured_content
2489 .as_object()
2490 .expect("structured_content should be a JSON object");
2491 for property in &required_properties {
2492 assert!(
2493 structured_object.contains_key(*property),
2494 "structured_content is missing required property {property}"
2495 );
2496 }
2497 assert_eq!(
2498 structured_content["selected"]["platform"],
2499 serde_json::json!("apple_simulator")
2500 );
2501 }
2502
2503 fn apple_simulator(name: &str) -> SelectedTarget {
2504 SelectedTarget::AppleSimulator {
2505 simulator_name: Some(name.to_string()),
2506 simulator_udid: None,
2507 controlkit_port: None,
2508 }
2509 }
2510
2511 #[test]
2512 fn resolves_android_target_from_explicit_serial() {
2513 let target = resolve_selected_target(
2514 None,
2515 None,
2516 None,
2517 None,
2518 Some("emulator-5554".to_string()),
2519 None,
2520 )
2521 .expect("target should resolve");
2522 assert_eq!(
2523 target,
2524 SelectedTarget::Android {
2525 serial: Some("emulator-5554".to_string())
2526 }
2527 );
2528 }
2529
2530 #[test]
2531 fn resolves_apple_simulator_target_from_explicit_name() {
2532 let target =
2533 resolve_selected_target(Some("iPhone 16".to_string()), None, None, None, None, None)
2534 .expect("target should resolve");
2535 assert_eq!(target, apple_simulator("iPhone 16"));
2536 }
2537
2538 #[test]
2539 fn resolves_apple_host_target_from_explicit_host() {
2540 let target = resolve_selected_target(
2541 None,
2542 None,
2543 Some("192.0.2.1".to_string()),
2544 Some(12005),
2545 None,
2546 None,
2547 )
2548 .expect("target should resolve");
2549 assert_eq!(
2550 target,
2551 SelectedTarget::AppleHost {
2552 host: "192.0.2.1".to_string(),
2553 controlkit_port: Some(12005),
2554 }
2555 );
2556 }
2557
2558 #[test]
2559 fn falls_back_to_stored_target_when_nothing_provided() {
2560 let stored = apple_simulator("iPad");
2561 let target = resolve_selected_target(None, None, None, None, None, Some(stored.clone()))
2562 .expect("target should resolve");
2563 assert_eq!(target, stored);
2564 }
2565
2566 #[test]
2567 fn explicit_fields_override_stored_target() {
2568 let stored = apple_simulator("iPad");
2569 let target = resolve_selected_target(
2570 None,
2571 None,
2572 None,
2573 None,
2574 Some("emulator-5554".to_string()),
2575 Some(stored),
2576 )
2577 .expect("target should resolve");
2578 assert_eq!(
2579 target,
2580 SelectedTarget::Android {
2581 serial: Some("emulator-5554".to_string())
2582 }
2583 );
2584 }
2585
2586 #[test]
2587 fn rejects_missing_target_with_no_stored_fallback() {
2588 let error = resolve_selected_target(None, None, None, None, None, None)
2589 .expect_err("target should not resolve");
2590 assert!(error.contains("No target provided"));
2591 }
2592
2593 #[test]
2594 fn rejects_ambiguous_android_and_apple_simulator_fields() {
2595 let error = resolve_selected_target(
2596 Some("iPhone 16".to_string()),
2597 None,
2598 None,
2599 None,
2600 Some("emulator-5554".to_string()),
2601 None,
2602 )
2603 .expect_err("target should not resolve");
2604 assert!(error.contains("Ambiguous"));
2605 }
2606
2607 #[test]
2608 fn rejects_ambiguous_android_and_apple_host_fields() {
2609 let error = resolve_selected_target(
2610 None,
2611 None,
2612 Some("192.0.2.1".to_string()),
2613 None,
2614 Some("emulator-5554".to_string()),
2615 None,
2616 )
2617 .expect_err("target should not resolve");
2618 assert!(error.contains("Ambiguous"));
2619 }
2620
2621 #[test]
2622 fn rejects_ambiguous_simulator_and_host_fields() {
2623 let error = resolve_selected_target(
2624 Some("iPhone 16".to_string()),
2625 None,
2626 Some("192.0.2.1".to_string()),
2627 None,
2628 None,
2629 None,
2630 )
2631 .expect_err("target should not resolve");
2632 assert!(error.contains("Ambiguous"));
2633 }
2634
2635 #[test]
2636 fn requires_apple_target_accepts_apple_simulator() {
2637 require_apple_target("ui_describe", &apple_simulator("iPhone 16"))
2638 .expect("apple simulator should be accepted");
2639 }
2640
2641 #[test]
2642 fn requires_apple_target_accepts_apple_host() {
2643 require_apple_target(
2644 "ui_describe",
2645 &SelectedTarget::AppleHost {
2646 host: "192.0.2.1".to_string(),
2647 controlkit_port: None,
2648 },
2649 )
2650 .expect("apple host should be accepted");
2651 }
2652
2653 #[test]
2654 fn requires_apple_target_rejects_android_with_tool_name_and_serial() {
2655 let error = require_apple_target(
2656 "ui_describe",
2657 &SelectedTarget::Android {
2658 serial: Some("emulator-5554".to_string()),
2659 },
2660 )
2661 .expect_err("android target should be rejected");
2662 assert!(error.contains("ui_describe"));
2663 assert!(error.contains("emulator-5554"));
2664 }
2665
2666 #[test]
2667 fn converts_finite_coordinates_to_rounded_pixels() {
2668 assert_eq!(to_android_coordinate(12.4).unwrap(), 12);
2669 assert_eq!(to_android_coordinate(12.6).unwrap(), 13);
2670 // Rust's f64::round() rounds half away from zero.
2671 assert_eq!(to_android_coordinate(-3.5).unwrap(), -4);
2672 }
2673
2674 #[test]
2675 fn rejects_non_finite_coordinates() {
2676 assert!(to_android_coordinate(f64::NAN).is_err());
2677 assert!(to_android_coordinate(f64::INFINITY).is_err());
2678 }
2679
2680 #[test]
2681 fn parses_valid_apple_buttons_case_sensitively() {
2682 assert_eq!(AppleButton::parse("home"), Some(AppleButton::Home));
2683 assert_eq!(
2684 AppleButton::parse("playPause"),
2685 Some(AppleButton::PlayPause)
2686 );
2687 assert_eq!(AppleButton::parse("playpause"), None);
2688 assert_eq!(AppleButton::parse("back"), None);
2689 }
2690
2691 #[test]
2692 fn parses_valid_android_buttons() {
2693 assert_eq!(AndroidButton::parse("back"), Some(AndroidButton::Back));
2694 assert_eq!(
2695 AndroidButton::parse("recents"),
2696 Some(AndroidButton::Recents)
2697 );
2698 assert_eq!(AndroidButton::parse("menu"), None);
2699 }
2700
2701 #[test]
2702 fn orientation_as_str_matches_controlkit_vocabulary() {
2703 assert_eq!(Orientation::Portrait.as_str(), "PORTRAIT");
2704 assert_eq!(Orientation::Landscape.as_str(), "LANDSCAPE");
2705 }
2706
2707 #[test]
2708 fn android_capabilities_lists_supported_and_unsupported_actions() {
2709 let capabilities = android_capabilities();
2710 assert_eq!(capabilities["screen_capture"], serde_json::json!(true));
2711 assert_eq!(capabilities["ui_describe"], serde_json::json!(false));
2712 assert_eq!(
2713 capabilities["input_button"]["buttons"],
2714 serde_json::json!(AndroidButton::ALL)
2715 );
2716 }
2717 }