1 file changed
+94
-4
crates/xcrs/src/mcp.rs
+94
-4
@@ -568,6 +568,24 @@ pub struct ButtonArgs {
568
pub button: ButtonName,
569
}
570
571
+#[derive(Debug, Deserialize, JsonSchema)]
572
+pub struct AppleTargetArgs {
573
+ /// Exact Apple simulator name. Omit to use the target selected with
574
+ /// `device_select`.
575
+ #[serde(default)]
576
+ pub simulator_name: Option<String>,
577
+ /// Apple simulator UDID. Omit to use the target selected with `device_select`.
578
+ #[serde(default)]
579
+ pub simulator_udid: Option<String>,
580
+ /// ControlKit host of a physical device or remote runner. Omit for a local
581
+ /// simulator.
582
+ #[serde(default)]
583
+ pub host: Option<String>,
584
+ /// Local ControlKit JSON-RPC port. Defaults to 12004.
585
+ #[serde(default)]
586
+ pub controlkit_port: Option<u16>,
587
+}
588
+
589
#[derive(Debug, Deserialize, JsonSchema)]
590
pub struct UiTargetArgs {
591
/// Exact Apple simulator name. Omit to use the target selected with
@@ -585,7 +603,8 @@ pub struct UiTargetArgs {
603
#[serde(default)]
604
pub controlkit_port: Option<u16>,
605
/// Bundle identifier of the app whose accessibility hierarchy should be read.
588
- pub bundle_id: String,
606
+ #[serde(default)]
607
+ pub bundle_id: Option<String>,
608
}
609
610
#[derive(Debug, Deserialize, JsonSchema)]
@@ -678,6 +697,25 @@ macro_rules! xcrs_mcp_tools {
697
.and_then(|slot| slot.clone())
698
}
699
700
+ fn require_bundle_id(
701
+ tool_name: &str,
702
+ bundle_id: Option<String>,
703
+ ) -> ::std::result::Result<String, ::rmcp::model::ErrorData> {
704
+ let bundle_id = bundle_id
705
+ .as_deref()
706
+ .map(str::trim)
707
+ .filter(|bundle_id| !bundle_id.is_empty())
708
+ .ok_or_else(|| {
709
+ ::rmcp::model::ErrorData::invalid_request(
710
+ format!(
711
+ "{tool_name} requires bundle_id. Pass the bundle identifier of the foreground Apple app."
712
+ ),
713
+ None,
714
+ )
715
+ })?;
716
+ Ok(bundle_id.to_string())
717
+ }
718
+
719
/// Validate and tag a target from per-call fields, falling back to the
720
/// target selected with `device_select` when no field is provided.
721
fn dispatch_target(
@@ -1332,6 +1370,7 @@ macro_rules! xcrs_mcp_tools {
1370
::rmcp::model::CallToolResult,
1371
::rmcp::model::ErrorData,
1372
> {
1373
+ let bundle_id = Self::require_bundle_id($ui_describe_name, args.bundle_id)?;
1374
let target = Self::dispatch_apple_target(
1375
$ui_describe_name,
1376
args.simulator_name,
@@ -1345,7 +1384,7 @@ macro_rules! xcrs_mcp_tools {
1384
"device.dump.ui",
1385
::serde_json::json!({
1386
"format": "json",
1348
- "bundleId": args.bundle_id,
1387
+ "bundleId": bundle_id,
1388
}),
1389
)
1390
.await
@@ -1377,6 +1416,7 @@ macro_rules! xcrs_mcp_tools {
1416
::rmcp::model::CallToolResult,
1417
::rmcp::model::ErrorData,
1418
> {
1419
+ let bundle_id = Self::require_bundle_id($ui_element_list_name, args.bundle_id)?;
1420
let target = Self::dispatch_apple_target(
1421
$ui_element_list_name,
1422
args.simulator_name,
@@ -1390,7 +1430,7 @@ macro_rules! xcrs_mcp_tools {
1430
"device.dump.ui",
1431
::serde_json::json!({
1432
"format": "json",
1393
- "bundleId": args.bundle_id,
1433
+ "bundleId": bundle_id,
1434
}),
1435
)
1436
.await
@@ -1774,7 +1814,7 @@ macro_rules! xcrs_mcp_tools {
1814
::rmcp::handler::server::wrapper::Parameters(
1815
args,
1816
): ::rmcp::handler::server::wrapper::Parameters<
1777
- $crate::mcp::UiTargetArgs,
1817
+ $crate::mcp::AppleTargetArgs,
1818
>,
1819
) -> ::std::result::Result<
1820
::rmcp::model::CallToolResult,
@@ -2151,6 +2191,56 @@ mod tests {
2191
}
2192
}
2193
2194
+ #[test]
2195
+ fn ui_tools_keep_bundle_id_optional_in_the_input_schema() {
2196
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2197
+ for tool_name in ["ui_describe", "ui_element_list"] {
2198
+ let tool = tools
2199
+ .iter()
2200
+ .find(|tool| tool.name.as_ref() == tool_name)
2201
+ .unwrap_or_else(|| panic!("{tool_name} should be registered"));
2202
+ let required = tool
2203
+ .input_schema
2204
+ .get("required")
2205
+ .and_then(serde_json::Value::as_array)
2206
+ .cloned()
2207
+ .unwrap_or_default();
2208
+
2209
+ assert!(tool.input_schema["properties"].get("bundle_id").is_some());
2210
+ assert!(!required.contains(&serde_json::json!("bundle_id")));
2211
+ }
2212
+ }
2213
+
2214
+ #[test]
2215
+ fn orientation_get_does_not_advertise_bundle_id() {
2216
+ let tools = XcrsMcpServer::xcrs_tool_router().list_all();
2217
+ let tool = tools
2218
+ .iter()
2219
+ .find(|tool| tool.name.as_ref() == "orientation_get")
2220
+ .expect("orientation_get should be registered");
2221
+
2222
+ assert!(tool.input_schema["properties"].get("bundle_id").is_none());
2223
+ }
2224
+
2225
+ #[test]
2226
+ fn ui_target_args_accept_omitted_bundle_id_and_validate_it_explicitly() {
2227
+ let args: UiTargetArgs =
2228
+ serde_json::from_value(serde_json::json!({})).expect("arguments should deserialize");
2229
+
2230
+ assert!(args.bundle_id.is_none());
2231
+ let error = XcrsMcpServer::require_bundle_id("ui_describe", args.bundle_id)
2232
+ .expect_err("missing bundle_id should fail validation");
2233
+ assert!(error.to_string().contains("ui_describe requires bundle_id"));
2234
+ assert_eq!(
2235
+ XcrsMcpServer::require_bundle_id(
2236
+ "ui_describe",
2237
+ Some(" com.example.app ".to_string())
2238
+ )
2239
+ .expect("non-empty bundle_id should pass validation"),
2240
+ "com.example.app"
2241
+ );
2242
+ }
2243
+
2244
#[test]
2245
fn output_schema_is_present_only_where_structured_content_is_guaranteed() {
2246
let tools = XcrsMcpServer::xcrs_tool_router().list_all();