@smbcloud / cli / commits / 75f5b51

fix: preserve UI tool argument compatibility

Seto Elkahfi committed Aug 25, 2026 at 13:15 UTC 75f5b51b6f72471d4059855aa766ff1c7d24e31b
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();