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