From afd8546ecec8462b884dda72378ad26b0f021b1e Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:44:08 +0200 Subject: [PATCH 01/10] Add xcrs crate --- Cargo.lock | 14 ++ Cargo.toml | 3 +- crates/cli/Cargo.toml | 1 + crates/cli/src/mcp.rs | 9 +- crates/cli/src/mcp_xcrs.rs | 8 + crates/xcrs/Cargo.toml | 27 +++ crates/xcrs/src/main.rs | 111 ++++++++++ crates/xcrs/src/mcp.rs | 170 ++++++++++++++++ crates/xcrs/src/xcrs.rs | 401 +++++++++++++++++++++++++++++++++++++ 9 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 crates/cli/src/mcp_xcrs.rs create mode 100644 crates/xcrs/Cargo.toml create mode 100644 crates/xcrs/src/main.rs create mode 100644 crates/xcrs/src/mcp.rs create mode 100644 crates/xcrs/src/xcrs.rs diff --git a/Cargo.lock b/Cargo.lock index fa3fe7c6..ff4ecd38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "tracing-bunyan-formatter", "tracing-subscriber", "url-builder", + "xcrs", ] [[package]] @@ -3993,6 +3994,19 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xcrs" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap 4.6.2", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index a1e08eeb..cfb757dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/cli", "crates/smbcloud-*"] +members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"] [workspace.dependencies] anyhow = "1.0.58" @@ -55,3 +55,4 @@ tsync = "2" uniffi = "0.31" url-builder = "0.1.1" wasm-bindgen = "0.2" +xcrs = { version = "0.1.0", path = "crates/xcrs" } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4f4bc265..5eac4686 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -63,6 +63,7 @@ tracing = { workspace = true, features = ["log"] } tracing-bunyan-formatter = { workspace = true } tracing-subscriber = { workspace = true, features = ["registry", "env-filter"] } url-builder = { workspace = true } +xcrs = { workspace = true } [target.'cfg(not(windows))'.dependencies] openssl = { workspace = true } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index e0dbe0ea..71b511ea 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -63,6 +63,9 @@ pub struct SmbMcpServer { environment: Environment, } +#[path = "mcp_xcrs.rs"] +mod mcp_xcrs; + impl SmbMcpServer { pub fn new(environment: Environment) -> Self { Self { environment } @@ -341,7 +344,7 @@ struct AuthAppDeleteArgs { id: String, } -#[tool_router] +#[tool_router(router = cloud_tool_router, vis = "pub(crate)")] impl SmbMcpServer { #[tool(description = "Get the authenticated smbCloud user's account info. \ Requires a prior `smb login`; returns the user as JSON.")] @@ -912,7 +915,9 @@ impl SmbMcpServer { } } -#[tool_handler] +#[tool_handler( + router = (Self::cloud_tool_router() + Self::xcrs_tool_router()) +)] impl ServerHandler for SmbMcpServer { fn get_info(&self) -> ServerInfo { // `Implementation` is `#[non_exhaustive]`, so start from the build-env diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs new file mode 100644 index 00000000..4491ef33 --- /dev/null +++ b/crates/cli/src/mcp_xcrs.rs @@ -0,0 +1,8 @@ +use super::SmbMcpServer; + +xcrs::xcrs_mcp_tools!( + SmbMcpServer, + "smb_simulator_list", + "smb_simulator_find", + "smb_ios_app_test" +); diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml new file mode 100644 index 00000000..2d468aa9 --- /dev/null +++ b/crates/xcrs/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "xcrs" +version = "0.1.0" +edition = "2021" +authors = ["Seto Elkahfi "] +description = "Rust abstraction for Xcode command line tools." +license = "Apache-2.0" +repository = "https://github.com/smbcloudXYZ/smbcloud-cli" +documentation = "https://smbcloud.xyz/posts" +keywords = ["xcode", "ios", "simulator", "cli"] +categories = ["command-line-utilities", "development-tools"] + +[lib] +path = "src/xcrs.rs" + +[[bin]] +name = "xcrs" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/crates/xcrs/src/main.rs b/crates/xcrs/src/main.rs new file mode 100644 index 00000000..7521991d --- /dev/null +++ b/crates/xcrs/src/main.rs @@ -0,0 +1,111 @@ +use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use xcrs::{IosAppTest, XcodeCommandLineTools}; + +#[derive(Debug, Parser)] +#[command(name = "xcrs")] +#[command(about = "Rust abstraction over Xcode command line tools")] +struct Cli { + /// Run as a standalone Model Context Protocol server over stdio. + #[arg(long)] + mcp: bool, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Commands { + #[command(subcommand)] + Simulator(SimulatorCommand), + IosAppTest { + #[arg(long, conflicts_with = "simulator_udid")] + simulator_name: Option, + #[arg(long, conflicts_with = "simulator_name")] + simulator_udid: Option, + #[arg(long)] + app_path: PathBuf, + #[arg(long)] + bundle_id: String, + #[arg(long)] + terminate_before_launch: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum SimulatorCommand { + Find { + #[arg(long)] + name: String, + #[arg(long)] + json: bool, + }, + List { + #[arg(long)] + json: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.mcp { + return xcrs::mcp::serve().await; + } + + let command = cli + .command + .ok_or_else(|| anyhow!("a command is required unless --mcp is provided"))?; + let tools = XcodeCommandLineTools::new(); + + match command { + Commands::Simulator(SimulatorCommand::Find { name, json }) => { + let simulator = tools.simctl().find_simulator_by_name(&name)?; + if json { + println!("{}", serde_json::to_string_pretty(&simulator)?); + } else { + println!("{} {}", simulator.name, simulator.udid); + } + } + Commands::Simulator(SimulatorCommand::List { json }) => { + let simulators = tools.simctl().list_simulators()?; + if json { + println!("{}", serde_json::to_string_pretty(&simulators)?); + } else { + for simulator in simulators { + println!("{} {} {}", simulator.name, simulator.udid, simulator.state); + } + } + } + Commands::IosAppTest { + simulator_name, + simulator_udid, + app_path, + bundle_id, + terminate_before_launch, + json, + } => { + let result = tools.run_ios_app_test(&IosAppTest { + simulator_name, + simulator_udid, + app_path, + bundle_id, + terminate_before_launch, + })?; + + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "Launched {} on {} ({})", + result.bundle_id, result.simulator.name, result.simulator.udid + ); + } + } + } + + Ok(()) +} diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs new file mode 100644 index 00000000..10658a59 --- /dev/null +++ b/crates/xcrs/src/mcp.rs @@ -0,0 +1,170 @@ +use { + anyhow::{anyhow, Result}, + rmcp::{ + model::{Implementation, ServerCapabilities, ServerInfo}, + transport::stdio, + ServerHandler, ServiceExt, + }, + schemars::JsonSchema, + serde::Deserialize, + std::path::PathBuf, +}; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorFindArgs { + /// Exact simulator name to find. + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct IosAppTestArgs { + /// Exact simulator name to use. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID to use. + #[serde(default)] + pub simulator_udid: Option, + /// Path to the `.app` bundle. + pub app_path: PathBuf, + /// Installed app bundle identifier. + pub bundle_id: String, + /// Terminate the app before launching it. + #[serde(default)] + pub terminate_before_launch: bool, +} + +#[derive(Debug, Default)] +pub struct XcrsMcpServer; + +impl XcrsMcpServer { + pub fn new() -> Self { + Self + } +} + +#[macro_export] +macro_rules! xcrs_mcp_tools { + ($server:ty, $simulator_list_name:literal, $simulator_find_name:literal, $ios_app_test_name:literal) => { + #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] + impl $server { + #[::rmcp::tool( + name = $simulator_list_name, + description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state." + )] + async fn simulator_list( + &self, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulators = $crate::XcodeCommandLineTools::new() + .simctl() + .list_simulators() + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&simulators)?, + ])) + } + + #[::rmcp::tool( + name = $simulator_find_name, + description = "Find an iOS simulator by its exact name and return its details as JSON." + )] + async fn simulator_find( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorFindArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulator = $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_name(&args.name) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&simulator)?, + ])) + } + + #[::rmcp::tool( + name = $ios_app_test_name, + description = "Boot an iOS simulator, install an .app bundle, optionally terminate the bundle, launch it, and return its app container path." + )] + async fn ios_app_test( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::IosAppTestArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + if args.simulator_name.is_none() && args.simulator_udid.is_none() { + return Err(::rmcp::model::ErrorData::invalid_request( + "Provide either simulator_name or simulator_udid.", + None, + )); + } + + let result = $crate::XcodeCommandLineTools::new() + .run_ios_app_test(&$crate::IosAppTest { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + app_path: args.app_path, + bundle_id: args.bundle_id, + terminate_before_launch: args.terminate_before_launch, + }) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&result)?, + ])) + } + } + }; +} + +xcrs_mcp_tools!( + XcrsMcpServer, + "xcrs_simulator_list", + "xcrs_simulator_find", + "xcrs_ios_app_test" +); + +#[rmcp::tool_handler(router = Self::xcrs_tool_router())] +impl ServerHandler for XcrsMcpServer { + fn get_info(&self) -> ServerInfo { + let mut implementation = Implementation::from_build_env(); + implementation.name = "xcrs".to_string(); + implementation.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(implementation) + .with_instructions( + "xcrs exposes Xcode command line tools for iOS simulators and app testing.", + ) + } +} + +pub async fn serve() -> Result<()> { + let running = XcrsMcpServer::new() + .serve(stdio()) + .await + .map_err(|error| anyhow!("Failed to start xcrs MCP server: {error}"))?; + running + .waiting() + .await + .map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?; + Ok(()) +} diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs new file mode 100644 index 00000000..ff8009b8 --- /dev/null +++ b/crates/xcrs/src/xcrs.rs @@ -0,0 +1,401 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +pub mod mcp; + +const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id="; + +#[derive(Debug, Clone)] +pub struct XcodeCommandLineTools { + xcrun_path: PathBuf, + xcodebuild_path: PathBuf, +} + +impl Default for XcodeCommandLineTools { + fn default() -> Self { + Self { + xcrun_path: PathBuf::from("xcrun"), + xcodebuild_path: PathBuf::from("xcodebuild"), + } + } +} + +impl XcodeCommandLineTools { + pub fn new() -> Self { + Self::default() + } + + pub fn with_paths(xcrun_path: impl Into, xcodebuild_path: impl Into) -> Self { + Self { + xcrun_path: xcrun_path.into(), + xcodebuild_path: xcodebuild_path.into(), + } + } + + pub fn simctl(&self) -> Simctl<'_> { + Simctl { tools: self } + } + + pub fn xcodebuild(&self) -> Xcodebuild<'_> { + Xcodebuild { tools: self } + } + + fn run_xcrun(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + run_command(&self.xcrun_path, args) + } + + fn run_xcodebuild(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + run_command(&self.xcodebuild_path, args) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Simulator { + pub runtime_identifier: String, + pub name: String, + pub udid: String, + pub state: String, + pub is_available: bool, +} + +impl Simulator { + pub fn is_booted(&self) -> bool { + self.state == "Booted" + } +} + +#[derive(Debug, Deserialize)] +struct SimctlDeviceList { + devices: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct SimctlDevice { + name: String, + udid: String, + state: String, + #[serde(rename = "isAvailable")] + is_available: Option, +} + +pub struct Simctl<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Simctl<'_> { + pub fn list_simulators(&self) -> Result> { + let output = self + .tools + .run_xcrun(["simctl", "list", "devices", "--json"])?; + parse_simctl_devices(&output) + } + + pub fn find_simulator_by_name(&self, name: &str) -> Result { + self.list_simulators()? + .into_iter() + .find(|simulator| simulator.name == name) + .ok_or_else(|| anyhow!("iOS simulator named '{name}' was not found")) + } + + pub fn find_simulator_by_udid(&self, udid: &str) -> Result { + self.list_simulators()? + .into_iter() + .find(|simulator| simulator.udid == udid) + .ok_or_else(|| anyhow!("iOS simulator with UDID '{udid}' was not found")) + } + + pub fn boot(&self, udid: &str) -> Result<()> { + if self.find_simulator_by_udid(udid)?.is_booted() { + return Ok(()); + } + + self.tools.run_xcrun(["simctl", "boot", udid])?; + Ok(()) + } + + pub fn shutdown(&self, udid: &str) -> Result<()> { + if !self.find_simulator_by_udid(udid)?.is_booted() { + return Ok(()); + } + + self.tools.run_xcrun(["simctl", "shutdown", udid])?; + Ok(()) + } + + pub fn install_app(&self, udid: &str, app_path: impl AsRef) -> Result<()> { + let app_path = app_path.as_ref(); + ensure_path_exists(app_path)?; + + let path = app_path + .to_str() + .ok_or_else(|| anyhow!("app path is not valid UTF-8: {}", app_path.display()))?; + + self.tools.run_xcrun(["simctl", "install", udid, path])?; + Ok(()) + } + + pub fn uninstall_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "uninstall", udid, bundle_id])?; + Ok(()) + } + + pub fn launch_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "launch", udid, bundle_id])?; + Ok(()) + } + + pub fn terminate_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "terminate", udid, bundle_id])?; + Ok(()) + } + + pub fn open_url(&self, udid: &str, url: &str) -> Result<()> { + self.tools.run_xcrun(["simctl", "openurl", udid, url])?; + Ok(()) + } + + pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result { + let output = + self.tools + .run_xcrun(["simctl", "get_app_container", udid, bundle_id, "app"])?; + let path = output.trim(); + if path.is_empty() { + return Err(anyhow!( + "simctl returned an empty app container path for '{bundle_id}'" + )); + } + + Ok(PathBuf::from(path)) + } +} + +#[derive(Debug, Clone)] +pub enum XcodeProject { + Project(PathBuf), + Workspace(PathBuf), +} + +#[derive(Debug, Clone)] +pub struct BuildIosApp { + pub project: XcodeProject, + pub scheme: String, + pub simulator_udid: String, + pub derived_data_path: PathBuf, + pub configuration: Option, +} + +pub struct Xcodebuild<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Xcodebuild<'_> { + pub fn build_ios_app(&self, request: &BuildIosApp) -> Result<()> { + let mut args = Vec::new(); + match &request.project { + XcodeProject::Project(project_path) => { + ensure_path_exists(project_path)?; + args.push("-project".to_string()); + args.push(path_to_string(project_path)?); + } + XcodeProject::Workspace(workspace_path) => { + ensure_path_exists(workspace_path)?; + args.push("-workspace".to_string()); + args.push(path_to_string(workspace_path)?); + } + } + + args.push("-scheme".to_string()); + args.push(request.scheme.clone()); + args.push("-destination".to_string()); + args.push(format!( + "{IOS_SIMULATOR_DESTINATION_PREFIX}{}", + request.simulator_udid + )); + args.push("-derivedDataPath".to_string()); + args.push(path_to_string(&request.derived_data_path)?); + + if let Some(configuration) = &request.configuration { + args.push("-configuration".to_string()); + args.push(configuration.clone()); + } + + args.push("build".to_string()); + + self.tools.run_xcodebuild(args)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct IosAppTestResult { + pub simulator: Simulator, + pub bundle_id: String, + pub app_container_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct IosAppTest { + pub simulator_name: Option, + pub simulator_udid: Option, + pub app_path: PathBuf, + pub bundle_id: String, + pub terminate_before_launch: bool, +} + +impl XcodeCommandLineTools { + pub fn run_ios_app_test(&self, request: &IosAppTest) -> Result { + if request.simulator_name.is_none() && request.simulator_udid.is_none() { + return Err(anyhow!( + "either simulator_name or simulator_udid must be provided" + )); + } + + let simctl = self.simctl(); + let simulator = match (&request.simulator_udid, &request.simulator_name) { + (Some(udid), _) => simctl.find_simulator_by_udid(udid)?, + (None, Some(name)) => simctl.find_simulator_by_name(name)?, + (None, None) => unreachable!("validated above"), + }; + + simctl.boot(&simulator.udid)?; + simctl.install_app(&simulator.udid, &request.app_path)?; + + if request.terminate_before_launch { + simctl.terminate_app(&simulator.udid, &request.bundle_id)?; + } + + simctl.launch_app(&simulator.udid, &request.bundle_id)?; + let app_container_path = simctl.app_container_path(&simulator.udid, &request.bundle_id)?; + + Ok(IosAppTestResult { + simulator: simctl.find_simulator_by_udid(&simulator.udid)?, + bundle_id: request.bundle_id.clone(), + app_container_path, + }) + } +} + +pub fn parse_simctl_devices(output: &str) -> Result> { + let list: SimctlDeviceList = + serde_json::from_str(output).context("failed to parse simctl device list JSON")?; + + let mut simulators = Vec::new(); + for (runtime_identifier, devices) in list.devices { + for device in devices { + simulators.push(Simulator { + runtime_identifier: runtime_identifier.clone(), + name: device.name, + udid: device.udid, + state: device.state, + is_available: device.is_available.unwrap_or(true), + }); + } + } + + Ok(simulators) +} + +fn ensure_path_exists(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("path does not exist: {}", path.display())); + } + + Ok(()) +} + +fn path_to_string(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display())) +} + +fn run_command(program: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {}", program.display()))?; + + command_output_to_result(program, output) +} + +fn command_output_to_result(program: &Path, output: Output) -> Result { + if output.status.success() { + return String::from_utf8(output.stdout) + .with_context(|| format!("{} wrote invalid UTF-8 to stdout", program.display())); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let message = [stderr.trim(), stdout.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + + if message.is_empty() { + return Err(anyhow!( + "{} exited with status {}", + program.display(), + output.status + )); + } + + Err(anyhow!( + "{} exited with status {}: {}", + program.display(), + output.status, + message + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simctl_devices() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-0": [ + { + "lastBootedAt": "2026-07-30T08:00:00Z", + "dataPath": "/tmp/device", + "dataPathSize": 1, + "logPath": "/tmp/logs", + "udid": "00000000-0000-0000-0000-000000000000", + "isAvailable": true, + "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + "state": "Booted", + "name": "Test-iOS-26" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].name, "Test-iOS-26"); + assert_eq!(devices[0].state, "Booted"); + assert!(devices[0].is_booted()); + } +} From c9949dd6ae649c8bae83162c92695dd62b24ee5e Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:58:55 +0200 Subject: [PATCH 02/10] Add simctl abstractions --- Cargo.lock | 2 + Cargo.toml | 1 + crates/cli/src/mcp_xcrs.rs | 6 +- crates/xcrs/Cargo.toml | 2 + crates/xcrs/src/mcp.rs | 205 ++++++++++++++++++++++++++++++++++++- crates/xcrs/src/xcrs.rs | 20 ++++ 6 files changed, 233 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff4ecd38..a4851bae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3999,11 +3999,13 @@ name = "xcrs" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap 4.6.2", "rmcp", "schemars", "serde", "serde_json", + "tempfile", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index cfb757dc..10b2e4bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"] [workspace.dependencies] anyhow = "1.0.58" async-trait = "0.1.51" +base64 = "0.22" chrono = "0.4" clap = "4.1.6" clap_mangen = "0.2.10" diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index 4491ef33..c8b95253 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -4,5 +4,9 @@ xcrs::xcrs_mcp_tools!( SmbMcpServer, "smb_simulator_list", "smb_simulator_find", - "smb_ios_app_test" + "smb_ios_app_test", + "smb_simulator_screenshot", + "smb_simulator_launch_app", + "smb_simulator_terminate_app", + "smb_simulator_open_url" ); diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml index 2d468aa9..b6898646 100644 --- a/crates/xcrs/Cargo.toml +++ b/crates/xcrs/Cargo.toml @@ -19,9 +19,11 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } +base64 = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } rmcp = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 10658a59..3dba20a5 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -33,6 +33,40 @@ pub struct IosAppTestArgs { pub terminate_before_launch: bool, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTargetArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorAppArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// App bundle identifier. + pub bundle_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorOpenUrlArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// HTTP(S) URL or custom URL scheme. + pub url: String, +} + #[derive(Debug, Default)] pub struct XcrsMcpServer; @@ -44,9 +78,42 @@ impl XcrsMcpServer { #[macro_export] macro_rules! xcrs_mcp_tools { - ($server:ty, $simulator_list_name:literal, $simulator_find_name:literal, $ios_app_test_name:literal) => { + ( + $server:ty, + $simulator_list_name:literal, + $simulator_find_name:literal, + $ios_app_test_name:literal, + $simulator_screenshot_name:literal, + $simulator_launch_app_name:literal, + $simulator_terminate_app_name:literal, + $simulator_open_url_name:literal + ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { + fn simulator_from_target( + target: &$crate::mcp::SimulatorTargetArgs, + ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> { + let tools = $crate::XcodeCommandLineTools::new(); + match (&target.simulator_udid, &target.simulator_name) { + (Some(udid), _) => tools + .simctl() + .find_simulator_by_udid(udid) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + }), + (None, Some(name)) => tools + .simctl() + .find_simulator_by_name(name) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + }), + (None, None) => Err(::rmcp::model::ErrorData::invalid_request( + "Provide either simulator_name or simulator_udid.", + None, + )), + } + } + #[::rmcp::tool( name = $simulator_list_name, description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state." @@ -131,6 +198,136 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::ContentBlock::json(&result)?, ])) } + + #[::rmcp::tool( + name = $simulator_screenshot_name, + description = "Capture a PNG screenshot from an iOS simulator." + )] + async fn simulator_screenshot( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTargetArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulator = Self::simulator_from_target(&args)?; + let screenshot = $crate::XcodeCommandLineTools::new() + .simctl() + .screenshot(&simulator.udid) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let encoded = $crate::encode_base64(screenshot); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::image(encoded, "image/png"), + ])) + } + + #[::rmcp::tool( + name = $simulator_launch_app_name, + description = "Launch an installed app on an iOS simulator by bundle identifier." + )] + async fn simulator_launch_app( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorAppArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .launch_app(&simulator.udid, &args.bundle_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Launched {} on {}.", + args.bundle_id, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_terminate_app_name, + description = "Terminate an installed app on an iOS simulator by bundle identifier." + )] + async fn simulator_terminate_app( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorAppArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .terminate_app(&simulator.udid, &args.bundle_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Terminated {} on {}.", + args.bundle_id, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_open_url_name, + description = "Open a URL or custom URL scheme on an iOS simulator." + )] + async fn simulator_open_url( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorOpenUrlArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .open_url(&simulator.udid, &args.url) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Opened {} on {}.", + args.url, simulator.name + )), + ])) + } } }; } @@ -139,7 +336,11 @@ xcrs_mcp_tools!( XcrsMcpServer, "xcrs_simulator_list", "xcrs_simulator_find", - "xcrs_ios_app_test" + "xcrs_ios_app_test", + "xcrs_simulator_screenshot", + "xcrs_simulator_launch_app", + "xcrs_simulator_terminate_app", + "xcrs_simulator_open_url" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index ff8009b8..a192033c 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -9,6 +9,12 @@ pub mod mcp; const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id="; +pub fn encode_base64(data: impl AsRef<[u8]>) -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + + STANDARD.encode(data) +} + #[derive(Debug, Clone)] pub struct XcodeCommandLineTools { xcrun_path: PathBuf, @@ -169,6 +175,20 @@ impl Simctl<'_> { Ok(()) } + pub fn screenshot(&self, udid: &str) -> Result> { + let temporary_directory = tempfile::tempdir()?; + let screenshot_path = temporary_directory.path().join("screenshot.png"); + let screenshot_path_string = screenshot_path + .to_str() + .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?; + + self.tools + .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?; + + std::fs::read(&screenshot_path) + .with_context(|| format!("failed to read {}", screenshot_path.display())) + } + pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result { let output = self.tools From 2d015565554086a497a3e4273097cb6f7b35970d Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:42:57 +0200 Subject: [PATCH 03/10] Add device controllable tools --- Cargo.lock | 1 + crates/cli/src/mcp_xcrs.rs | 11 +- crates/xcrs/Cargo.toml | 1 + crates/xcrs/src/mcp.rs | 497 ++++++++++++++++++++++++++++++++++++- crates/xcrs/src/xcrs.rs | 149 +++++++++++ 5 files changed, 656 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4851bae..88119639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4001,6 +4001,7 @@ dependencies = [ "anyhow", "base64", "clap 4.6.2", + "reqwest", "rmcp", "schemars", "serde", diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index c8b95253..94c2a08a 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -8,5 +8,14 @@ xcrs::xcrs_mcp_tools!( "smb_simulator_screenshot", "smb_simulator_launch_app", "smb_simulator_terminate_app", - "smb_simulator_open_url" + "smb_simulator_open_url", + "smb_simulator_ui_dump", + "smb_simulator_list_elements", + "smb_simulator_tap", + "smb_simulator_type_text", + "smb_simulator_swipe", + "smb_simulator_press_home", + "smb_simulator_press_button", + "smb_simulator_orientation_get", + "smb_simulator_orientation_set" ); diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml index b6898646..b6b5295f 100644 --- a/crates/xcrs/Cargo.toml +++ b/crates/xcrs/Cargo.toml @@ -25,5 +25,6 @@ rmcp = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 3dba20a5..5356cb88 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -41,6 +41,9 @@ pub struct SimulatorTargetArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -51,6 +54,9 @@ pub struct SimulatorAppArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, /// App bundle identifier. pub bundle_id: String, } @@ -63,10 +69,109 @@ pub struct SimulatorOpenUrlArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, /// HTTP(S) URL or custom URL scheme. pub url: String, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTapArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, + /// Horizontal screen coordinate. + pub x: f32, + /// Vertical screen coordinate. + pub y: f32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTextArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, + /// Text to type into the focused field. + pub text: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorSwipeArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, + /// Swipe start horizontal coordinate. + pub x1: i32, + /// Swipe start vertical coordinate. + pub y1: i32, + /// Swipe end horizontal coordinate. + pub x2: i32, + /// Swipe end vertical coordinate. + pub y2: i32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorOrientationArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, + /// Desired orientation: PORTRAIT or LANDSCAPE. + pub orientation: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorDeviceKitArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorButtonArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local DeviceKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub devicekit_port: Option, + /// tvOS button: up, down, left, right, select, menu, home, or playPause. + pub button: String, +} + #[derive(Debug, Default)] pub struct XcrsMcpServer; @@ -86,7 +191,16 @@ macro_rules! xcrs_mcp_tools { $simulator_screenshot_name:literal, $simulator_launch_app_name:literal, $simulator_terminate_app_name:literal, - $simulator_open_url_name:literal + $simulator_open_url_name:literal, + $simulator_ui_dump_name:literal, + $simulator_list_elements_name:literal, + $simulator_tap_name:literal, + $simulator_text_name:literal, + $simulator_swipe_name:literal, + $simulator_home_name:literal, + $simulator_button_name:literal, + $simulator_orientation_get_name:literal, + $simulator_orientation_set_name:literal ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { @@ -114,6 +228,26 @@ macro_rules! xcrs_mcp_tools { } } + fn devicekit_from_target( + simulator_name: &Option, + simulator_udid: &Option, + devicekit_port: Option, + ) -> ::std::result::Result< + ($crate::Simulator, $crate::DeviceKit), + ::rmcp::model::ErrorData, + > { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: simulator_name.clone(), + simulator_udid: simulator_udid.clone(), + devicekit_port, + }; + let simulator = Self::simulator_from_target(&target)?; + Ok(( + simulator, + $crate::DeviceKit::new(devicekit_port.unwrap_or(12004)), + )) + } + #[::rmcp::tool( name = $simulator_list_name, description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state." @@ -245,6 +379,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, + devicekit_port: args.devicekit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -279,6 +414,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, + devicekit_port: args.devicekit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -313,6 +449,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, + devicekit_port: args.devicekit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -328,6 +465,353 @@ macro_rules! xcrs_mcp_tools { )), ])) } + + #[::rmcp::tool( + name = $simulator_ui_dump_name, + description = "Return the accessibility UI hierarchy from the foreground iOS app through DeviceKit." + )] + async fn simulator_ui_dump( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorDeviceKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + let result = devicekit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "ui": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_list_elements_name, + description = "List actionable accessibility elements and coordinates from the foreground iOS app through DeviceKit." + )] + async fn simulator_list_elements( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorDeviceKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + let ui = devicekit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let elements = $crate::extract_devicekit_elements(&ui); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "elements": elements, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_tap_name, + description = "Tap the iOS simulator screen at the given coordinates through DeviceKit." + )] + async fn simulator_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTapArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + devicekit + .call( + "device.io.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Tapped ({}, {}) on {}.", + args.x, args.y, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_text_name, + description = "Type text into the focused iOS simulator field through DeviceKit." + )] + async fn simulator_text( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTextArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + devicekit + .call("device.io.text", ::serde_json::json!({ "text": args.text })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Typed text on {}.", + simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_swipe_name, + description = "Swipe between two screen coordinates on an iOS simulator through DeviceKit." + )] + async fn simulator_swipe( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorSwipeArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + devicekit + .call( + "device.io.swipe", + ::serde_json::json!({ + "x1": args.x1, + "y1": args.y1, + "x2": args.x2, + "y2": args.y2, + }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Swiped on {}.", + simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_home_name, + description = "Press the iOS simulator Home button through DeviceKit." + )] + async fn simulator_home( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorDeviceKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + devicekit + .call("device.io.button", ::serde_json::json!({ "button": "home" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Pressed Home on {}.", + simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_button_name, + description = "Press a DeviceKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause." + )] + async fn simulator_button( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorButtonArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + let supported_buttons = [ + "up", + "down", + "left", + "right", + "select", + "menu", + "home", + "playPause", + ]; + if !supported_buttons.contains(&args.button.as_str()) { + return Err(::rmcp::model::ErrorData::invalid_request( + "button must be one of up, down, left, right, select, menu, home, or playPause", + None, + )); + } + devicekit + .call( + "device.io.button", + ::serde_json::json!({ "button": args.button }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Pressed {} on {}.", + args.button, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_orientation_get_name, + description = "Read the current iOS simulator orientation through DeviceKit." + )] + async fn simulator_orientation_get( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorDeviceKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + let result = devicekit + .call("device.io.orientation.get", ::serde_json::json!({})) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "orientation": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_orientation_set_name, + description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through DeviceKit." + )] + async fn simulator_orientation_set( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorOrientationArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, devicekit) = Self::devicekit_from_target( + &args.simulator_name, + &args.simulator_udid, + args.devicekit_port, + )?; + let orientation = args.orientation.to_uppercase(); + if orientation != "PORTRAIT" && orientation != "LANDSCAPE" { + return Err(::rmcp::model::ErrorData::invalid_request( + "orientation must be PORTRAIT or LANDSCAPE", + None, + )); + } + devicekit + .call( + "device.io.orientation.set", + ::serde_json::json!({ "orientation": orientation }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Set orientation to {} on {}.", + orientation, simulator.name + )), + ])) + } } }; } @@ -340,7 +824,16 @@ xcrs_mcp_tools!( "xcrs_simulator_screenshot", "xcrs_simulator_launch_app", "xcrs_simulator_terminate_app", - "xcrs_simulator_open_url" + "xcrs_simulator_open_url", + "xcrs_simulator_ui_dump", + "xcrs_simulator_list_elements", + "xcrs_simulator_tap", + "xcrs_simulator_type_text", + "xcrs_simulator_swipe", + "xcrs_simulator_press_home", + "xcrs_simulator_press_button", + "xcrs_simulator_orientation_get", + "xcrs_simulator_orientation_set" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index a192033c..5d221966 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use std::time::Duration; pub mod mcp; @@ -15,6 +16,121 @@ pub fn encode_base64(data: impl AsRef<[u8]>) -> String { STANDARD.encode(data) } +pub fn extract_devicekit_elements(root: &serde_json::Value) -> Vec { + let mut elements = Vec::new(); + collect_devicekit_elements(root, &mut elements); + elements +} + +fn collect_devicekit_elements(element: &serde_json::Value, elements: &mut Vec) { + let object = match element.as_object() { + Some(object) => object, + None => return, + }; + + let rect = object.get("rect").and_then(serde_json::Value::as_object); + let has_visible_rect = rect + .and_then(|rect| { + Some(( + rect.get("x")?.as_f64()?, + rect.get("y")?.as_f64()?, + rect.get("width")?.as_f64()?, + rect.get("height")?.as_f64()?, + )) + }) + .is_some_and(|(x, y, width, height)| x >= 0.0 && y >= 0.0 && width > 0.0 && height > 0.0); + let has_identity = ["label", "name", "value", "rawIdentifier"] + .iter() + .any(|key| { + object + .get(*key) + .and_then(serde_json::Value::as_str) + .is_some() + }); + + if has_visible_rect && has_identity { + let keys = [ + "type", + "label", + "name", + "value", + "placeholderValue", + "rawIdentifier", + "rect", + ]; + let element = keys + .iter() + .filter_map(|key| { + object + .get(*key) + .map(|value| ((*key).to_string(), value.clone())) + }) + .collect(); + elements.push(serde_json::Value::Object(element)); + } + + if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) { + for child in children { + collect_devicekit_elements(child, elements); + } + } +} + +#[derive(Debug, Clone)] +pub struct DeviceKit { + client: reqwest::Client, + base_url: String, +} + +impl Default for DeviceKit { + fn default() -> Self { + Self::new(12004) + } +} + +impl DeviceKit { + pub fn new(port: u16) -> Self { + Self { + client: reqwest::Client::new(), + base_url: format!("http://127.0.0.1:{port}"), + } + } + + pub async fn call(&self, method: &str, params: serde_json::Value) -> Result { + let response = self + .client + .post(format!("{}/rpc", self.base_url)) + .timeout(Duration::from_secs(30)) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + })) + .send() + .await + .with_context(|| format!("failed to connect to DeviceKit at {}", self.base_url))?; + + let status = response.status(); + let body: serde_json::Value = response + .json() + .await + .context("failed to decode DeviceKit JSON-RPC response")?; + + if !status.is_success() { + return Err(anyhow!("DeviceKit returned HTTP {}: {}", status, body)); + } + + if let Some(error) = body.get("error") { + return Err(anyhow!("DeviceKit method '{method}' failed: {error}")); + } + + body.get("result") + .cloned() + .ok_or_else(|| anyhow!("DeviceKit response for '{method}' did not contain a result")) + } +} + #[derive(Debug, Clone)] pub struct XcodeCommandLineTools { xcrun_path: PathBuf, @@ -67,8 +183,39 @@ impl XcodeCommandLineTools { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum ApplePlatform { + #[serde(rename = "iOS")] + Ios, + #[serde(rename = "tvOS")] + Tvos, + #[serde(rename = "watchOS")] + Watchos, + #[serde(rename = "visionOS")] + Visionos, + #[serde(rename = "unknown")] + Unknown, +} + +impl ApplePlatform { + fn from_runtime_identifier(runtime_identifier: &str) -> Self { + if runtime_identifier.contains(".iOS-") { + Self::Ios + } else if runtime_identifier.contains(".tvOS-") { + Self::Tvos + } else if runtime_identifier.contains(".watchOS-") { + Self::Watchos + } else if runtime_identifier.contains(".visionOS-") { + Self::Visionos + } else { + Self::Unknown + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Simulator { + pub platform: ApplePlatform, pub runtime_identifier: String, pub name: String, pub udid: String, @@ -318,6 +465,7 @@ pub fn parse_simctl_devices(output: &str) -> Result> { for (runtime_identifier, devices) in list.devices { for device in devices { simulators.push(Simulator { + platform: ApplePlatform::from_runtime_identifier(&runtime_identifier), runtime_identifier: runtime_identifier.clone(), name: device.name, udid: device.udid, @@ -414,6 +562,7 @@ mod tests { let devices = parse_simctl_devices(output).expect("devices should parse"); assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Ios); assert_eq!(devices[0].name, "Test-iOS-26"); assert_eq!(devices[0].state, "Booted"); assert!(devices[0].is_booted()); From 8bce545036df165ef03cde5656c5714f2fda41b3 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:01:45 +0200 Subject: [PATCH 04/10] Use ControlKit --- crates/xcrs/src/mcp.rs | 136 ++++++++++++++++++++-------------------- crates/xcrs/src/xcrs.rs | 24 +++---- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 5356cb88..7581553f 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -41,9 +41,9 @@ pub struct SimulatorTargetArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -54,9 +54,9 @@ pub struct SimulatorAppArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// App bundle identifier. pub bundle_id: String, } @@ -69,9 +69,9 @@ pub struct SimulatorOpenUrlArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// HTTP(S) URL or custom URL scheme. pub url: String, } @@ -84,9 +84,9 @@ pub struct SimulatorTapArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// Horizontal screen coordinate. pub x: f32, /// Vertical screen coordinate. @@ -101,9 +101,9 @@ pub struct SimulatorTextArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// Text to type into the focused field. pub text: String, } @@ -116,9 +116,9 @@ pub struct SimulatorSwipeArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// Swipe start horizontal coordinate. pub x1: i32, /// Swipe start vertical coordinate. @@ -137,24 +137,24 @@ pub struct SimulatorOrientationArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// Desired orientation: PORTRAIT or LANDSCAPE. pub orientation: String, } #[derive(Debug, Deserialize, JsonSchema)] -pub struct SimulatorDeviceKitArgs { +pub struct SimulatorControlKitArgs { /// Exact simulator name. #[serde(default)] pub simulator_name: Option, /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -165,9 +165,9 @@ pub struct SimulatorButtonArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, - /// Local DeviceKit JSON-RPC port. Defaults to 12004. + /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] - pub devicekit_port: Option, + pub controlkit_port: Option, /// tvOS button: up, down, left, right, select, menu, home, or playPause. pub button: String, } @@ -228,23 +228,23 @@ macro_rules! xcrs_mcp_tools { } } - fn devicekit_from_target( + fn controlkit_from_target( simulator_name: &Option, simulator_udid: &Option, - devicekit_port: Option, + controlkit_port: Option, ) -> ::std::result::Result< - ($crate::Simulator, $crate::DeviceKit), + ($crate::Simulator, $crate::ControlKit), ::rmcp::model::ErrorData, > { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: simulator_name.clone(), simulator_udid: simulator_udid.clone(), - devicekit_port, + controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; Ok(( simulator, - $crate::DeviceKit::new(devicekit_port.unwrap_or(12004)), + $crate::ControlKit::new(controlkit_port.unwrap_or(12004)), )) } @@ -379,7 +379,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, - devicekit_port: args.devicekit_port, + controlkit_port: args.controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -414,7 +414,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, - devicekit_port: args.devicekit_port, + controlkit_port: args.controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -449,7 +449,7 @@ macro_rules! xcrs_mcp_tools { let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, - devicekit_port: args.devicekit_port, + controlkit_port: args.controlkit_port, }; let simulator = Self::simulator_from_target(&target)?; $crate::XcodeCommandLineTools::new() @@ -468,25 +468,25 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_ui_dump_name, - description = "Return the accessibility UI hierarchy from the foreground iOS app through DeviceKit." + description = "Return the accessibility UI hierarchy from the foreground iOS app through ControlKit." )] async fn simulator_ui_dump( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorDeviceKitArgs, + $crate::mcp::SimulatorControlKitArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - let result = devicekit + let result = controlkit .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) .await .map_err(|error| { @@ -502,31 +502,31 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_list_elements_name, - description = "List actionable accessibility elements and coordinates from the foreground iOS app through DeviceKit." + description = "List actionable accessibility elements and coordinates from the foreground iOS app through ControlKit." )] async fn simulator_list_elements( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorDeviceKitArgs, + $crate::mcp::SimulatorControlKitArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - let ui = devicekit + let ui = controlkit .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) .await .map_err(|error| { ::rmcp::model::ErrorData::internal_error(error.to_string(), None) })?; - let elements = $crate::extract_devicekit_elements(&ui); + let elements = $crate::extract_controlkit_elements(&ui); Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::json(&::serde_json::json!({ "simulator": simulator, @@ -537,7 +537,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_tap_name, - description = "Tap the iOS simulator screen at the given coordinates through DeviceKit." + description = "Tap the iOS simulator screen at the given coordinates through ControlKit." )] async fn simulator_tap( &self, @@ -550,12 +550,12 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - devicekit + controlkit .call( "device.io.tap", ::serde_json::json!({ "x": args.x, "y": args.y }), @@ -574,7 +574,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_text_name, - description = "Type text into the focused iOS simulator field through DeviceKit." + description = "Type text into the focused iOS simulator field through ControlKit." )] async fn simulator_text( &self, @@ -587,12 +587,12 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - devicekit + controlkit .call("device.io.text", ::serde_json::json!({ "text": args.text })) .await .map_err(|error| { @@ -608,7 +608,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_swipe_name, - description = "Swipe between two screen coordinates on an iOS simulator through DeviceKit." + description = "Swipe between two screen coordinates on an iOS simulator through ControlKit." )] async fn simulator_swipe( &self, @@ -621,12 +621,12 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - devicekit + controlkit .call( "device.io.swipe", ::serde_json::json!({ @@ -650,25 +650,25 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_home_name, - description = "Press the iOS simulator Home button through DeviceKit." + description = "Press the iOS simulator Home button through ControlKit." )] async fn simulator_home( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorDeviceKitArgs, + $crate::mcp::SimulatorControlKitArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - devicekit + controlkit .call("device.io.button", ::serde_json::json!({ "button": "home" })) .await .map_err(|error| { @@ -684,7 +684,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_button_name, - description = "Press a DeviceKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause." + description = "Press a ControlKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause." )] async fn simulator_button( &self, @@ -697,10 +697,10 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; let supported_buttons = [ "up", @@ -718,7 +718,7 @@ macro_rules! xcrs_mcp_tools { None, )); } - devicekit + controlkit .call( "device.io.button", ::serde_json::json!({ "button": args.button }), @@ -737,25 +737,25 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_orientation_get_name, - description = "Read the current iOS simulator orientation through DeviceKit." + description = "Read the current iOS simulator orientation through ControlKit." )] async fn simulator_orientation_get( &self, ::rmcp::handler::server::wrapper::Parameters( args, ): ::rmcp::handler::server::wrapper::Parameters< - $crate::mcp::SimulatorDeviceKitArgs, + $crate::mcp::SimulatorControlKitArgs, >, ) -> ::std::result::Result< ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; - let result = devicekit + let result = controlkit .call("device.io.orientation.get", ::serde_json::json!({})) .await .map_err(|error| { @@ -771,7 +771,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_orientation_set_name, - description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through DeviceKit." + description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through ControlKit." )] async fn simulator_orientation_set( &self, @@ -784,10 +784,10 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { - let (simulator, devicekit) = Self::devicekit_from_target( + let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, - args.devicekit_port, + args.controlkit_port, )?; let orientation = args.orientation.to_uppercase(); if orientation != "PORTRAIT" && orientation != "LANDSCAPE" { @@ -796,7 +796,7 @@ macro_rules! xcrs_mcp_tools { None, )); } - devicekit + controlkit .call( "device.io.orientation.set", ::serde_json::json!({ "orientation": orientation }), diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index 5d221966..9c9298ae 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -16,13 +16,13 @@ pub fn encode_base64(data: impl AsRef<[u8]>) -> String { STANDARD.encode(data) } -pub fn extract_devicekit_elements(root: &serde_json::Value) -> Vec { +pub fn extract_controlkit_elements(root: &serde_json::Value) -> Vec { let mut elements = Vec::new(); - collect_devicekit_elements(root, &mut elements); + collect_controlkit_elements(root, &mut elements); elements } -fn collect_devicekit_elements(element: &serde_json::Value, elements: &mut Vec) { +fn collect_controlkit_elements(element: &serde_json::Value, elements: &mut Vec) { let object = match element.as_object() { Some(object) => object, None => return, @@ -71,24 +71,24 @@ fn collect_devicekit_elements(element: &serde_json::Value, elements: &mut Vec Self { Self::new(12004) } } -impl DeviceKit { +impl ControlKit { pub fn new(port: u16) -> Self { Self { client: reqwest::Client::new(), @@ -109,25 +109,25 @@ impl DeviceKit { })) .send() .await - .with_context(|| format!("failed to connect to DeviceKit at {}", self.base_url))?; + .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?; let status = response.status(); let body: serde_json::Value = response .json() .await - .context("failed to decode DeviceKit JSON-RPC response")?; + .context("failed to decode ControlKit JSON-RPC response")?; if !status.is_success() { - return Err(anyhow!("DeviceKit returned HTTP {}: {}", status, body)); + return Err(anyhow!("ControlKit returned HTTP {}: {}", status, body)); } if let Some(error) = body.get("error") { - return Err(anyhow!("DeviceKit method '{method}' failed: {error}")); + return Err(anyhow!("ControlKit method '{method}' failed: {error}")); } body.get("result") .cloned() - .ok_or_else(|| anyhow!("DeviceKit response for '{method}' did not contain a result")) + .ok_or_else(|| anyhow!("ControlKit response for '{method}' did not contain a result")) } } From bb8cea64ae39f082423fbd43108d4d85ab198688 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:38:48 +0200 Subject: [PATCH 05/10] Add ControlKit commands --- crates/cli/src/cli/mod.rs | 49 ++++++++ crates/cli/src/controlkit.rs | 119 +++++++++++++++++++ crates/cli/src/lib.rs | 1 + crates/cli/src/main.rs | 3 + crates/xcrs/src/xcrs.rs | 217 ++++++++++++++++++++++++++++++++++- 5 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 crates/cli/src/controlkit.rs diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 06825713..21c3a43f 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -3,6 +3,7 @@ use { clap::{Parser, Subcommand}, smbcloud_network::environment::Environment, spinners::Spinner, + std::path::PathBuf, }; pub struct CommandResult { @@ -96,9 +97,57 @@ pub enum Commands { #[clap(subcommand)] command: cloud_auth::cli::Commands, }, + #[clap(about = "Run and control XCRS ControlKit on Apple devices.")] + ControlKit { + #[clap(subcommand)] + command: ControlKitCommands, + }, #[clap( about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.", display_order = 4 )] Migrate {}, } + +#[derive(Subcommand)] +pub enum ControlKitCommands { + #[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")] + Build { + #[arg(long)] + project_path: PathBuf, + #[arg(long, default_value = "ControlKit")] + scheme: String, + #[arg(long, default_value = "Release")] + configuration: String, + #[arg(long)] + device_udid: String, + #[arg(long)] + derived_data_path: PathBuf, + }, + #[clap(about = "Start a built ControlKit XCTest runner on a physical device.")] + Start { + #[arg(long)] + device_udid: String, + #[arg(long)] + xctestrun_path: PathBuf, + #[arg(long, default_value = "::")] + listen_host: String, + #[arg(long, default_value_t = 12004)] + listen_port: u16, + #[arg(long, default_value_t = 120)] + timeout_seconds: u64, + #[arg(long)] + log_path: Option, + }, + #[clap(about = "Call a running ControlKit JSON-RPC method.")] + Call { + #[arg(long)] + device_udid: String, + #[arg(long, default_value_t = 12004)] + port: u16, + #[arg(long)] + method: String, + #[arg(long, default_value = "{}")] + params: String, + }, +} diff --git a/crates/cli/src/controlkit.rs b/crates/cli/src/controlkit.rs new file mode 100644 index 00000000..ffd8d4ec --- /dev/null +++ b/crates/cli/src/controlkit.rs @@ -0,0 +1,119 @@ +use { + crate::{ + cli::{CommandResult, ControlKitCommands}, + ui::{succeed_message, succeed_symbol}, + }, + anyhow::{anyhow, Context, Result}, + spinners::{Spinner, Spinners}, + std::{path::PathBuf, time::Duration}, + xcrs::{ + BuildControlKitDeviceRunner, ControlKit, StartControlKitDeviceRunner, XcodeCommandLineTools, + }, +}; + +pub async fn process_controlkit(command: ControlKitCommands) -> Result { + match command { + ControlKitCommands::Build { + project_path, + scheme, + configuration, + device_udid, + derived_data_path, + } => { + let test_run_path = XcodeCommandLineTools::new().build_controlkit_device_runner( + &BuildControlKitDeviceRunner { + project_path, + scheme, + configuration, + device_udid, + derived_data_path, + }, + )?; + Ok(done_result(&format!( + "Built ControlKit runner: {}", + test_run_path.display() + ))) + } + ControlKitCommands::Start { + device_udid, + xctestrun_path, + listen_host, + listen_port, + timeout_seconds, + log_path, + } => { + let tools = XcodeCommandLineTools::new(); + let log_path = log_path.unwrap_or_else(|| { + std::env::temp_dir().join(format!("controlkit-{device_udid}.log")) + }); + let mut runner = + tools.start_controlkit_device_runner(&StartControlKitDeviceRunner { + device_udid, + xctestrun_path, + listen_host, + listen_port, + log_path: log_path.clone(), + })?; + let controlkit = ControlKit::with_host(&runner.tunnel_host, runner.listen_port); + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds); + + loop { + if controlkit.health().await.is_ok() { + break; + } + if let Some(status) = runner.process.try_wait()? { + return Err(runner_failure(status.to_string(), &runner.log_path)); + } + if tokio::time::Instant::now() >= deadline { + return Err(runner_failure( + format!("did not become healthy within {timeout_seconds} seconds"), + &runner.log_path, + )); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + Ok(done_result(&format!( + "ControlKit is running at http://[{}]:{} (PID {}, log {})", + runner.tunnel_host, + runner.listen_port, + runner.process.id(), + runner.log_path.display() + ))) + } + ControlKitCommands::Call { + device_udid, + port, + method, + params, + } => { + let tools = XcodeCommandLineTools::new(); + let tunnel_host = tools.device_tunnel_address(&device_udid)?; + let params = serde_json::from_str(¶ms) + .with_context(|| format!("invalid JSON for --params: {params}"))?; + let result = ControlKit::with_host(&tunnel_host, port) + .call(&method, params) + .await?; + Ok(done_result(&serde_json::to_string_pretty(&result)?)) + } + } +} + +fn runner_failure(reason: String, log_path: &PathBuf) -> anyhow::Error { + let log = std::fs::read_to_string(log_path).unwrap_or_default(); + let tail = log.lines().rev().take(20).collect::>(); + let tail = tail.into_iter().rev().collect::>().join("\n"); + if tail.is_empty() { + anyhow!("ControlKit runner {reason}; see {}", log_path.display()) + } else { + anyhow!("ControlKit runner {reason}:\n{tail}") + } +} + +fn done_result(message: &str) -> CommandResult { + CommandResult { + spinner: Spinner::new(Spinners::SimpleDotsScrolling, succeed_message("Done")), + symbol: succeed_symbol(), + msg: succeed_message(message), + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 57ba2e09..8ae2637e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5,6 +5,7 @@ pub mod ci; pub mod cli; #[path = "cloud-auth/mod.rs"] pub mod cloud_auth; +pub mod controlkit; #[path = "cloud-deploy/mod.rs"] pub mod deploy; pub mod interface; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c6526c21..abf20e3c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,6 +7,7 @@ use { clear_smb_token, cli::{Cli, CommandResult, Commands}, cloud_auth::process::process_cloud_auth, + controlkit::process_controlkit, deploy::{process_deploy::process_deploy, process_migrate::process_migrate}, mail::process::process_mail, project::{crud_create::process_project_init, process::process_project}, @@ -184,6 +185,7 @@ async fn run(cli: Cli) -> Result { | Some(Commands::Tenant { .. }) | Some(Commands::Migrate {}) | None => true, + Some(Commands::ControlKit { .. }) => false, Some(Commands::Init {}) => true, }; @@ -205,6 +207,7 @@ async fn run(cli: Cli) -> Result { Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await, Some(Commands::Project { command }) => process_project(cli.environment, command).await, Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await, + Some(Commands::ControlKit { command }) => process_controlkit(command).await, Some(Commands::Migrate {}) => process_migrate(cli.environment).await, None => process_deploy(cli.environment, None).await, } diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index 9c9298ae..53d0d647 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::process::{Child, Command, Output, Stdio}; use std::time::Duration; pub mod mcp; @@ -90,12 +90,40 @@ impl Default for ControlKit { impl ControlKit { pub fn new(port: u16) -> Self { + Self::with_host("127.0.0.1", port) + } + + pub fn with_host(host: &str, port: u16) -> Self { + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; Self { client: reqwest::Client::new(), - base_url: format!("http://127.0.0.1:{port}"), + base_url: format!("http://{host}:{port}"), } } + pub async fn health(&self) -> Result<()> { + let response = self + .client + .get(format!("{}/health", self.base_url)) + .timeout(Duration::from_secs(5)) + .send() + .await + .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?; + + if !response.status().is_success() { + return Err(anyhow!( + "ControlKit health check returned HTTP {}", + response.status() + )); + } + + Ok(()) + } + pub async fn call(&self, method: &str, params: serde_json::Value) -> Result { let response = self .client @@ -455,6 +483,176 @@ impl XcodeCommandLineTools { app_container_path, }) } + + pub fn device_tunnel_address(&self, device_udid: &str) -> Result { + let output = self.run_xcrun([ + "devicectl", + "device", + "info", + "details", + "--device", + device_udid, + ])?; + + parse_device_tunnel_address(&output) + .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address")) + } + + pub fn build_controlkit_device_runner( + &self, + request: &BuildControlKitDeviceRunner, + ) -> Result { + ensure_path_exists(&request.project_path)?; + let args = [ + "build-for-testing".to_string(), + "-project".to_string(), + path_to_string(&request.project_path)?, + "-scheme".to_string(), + request.scheme.clone(), + "-configuration".to_string(), + request.configuration.clone(), + "-destination".to_string(), + format!("id={}", request.device_udid), + "-derivedDataPath".to_string(), + path_to_string(&request.derived_data_path)?, + ]; + self.run_xcodebuild(args)?; + + let products_path = request.derived_data_path.join("Build/Products"); + let entries = std::fs::read_dir(&products_path) + .with_context(|| format!("failed to read {}", products_path.display()))?; + let mut test_run_paths = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "xctestrun") + }) + .collect::>(); + test_run_paths.sort(); + + test_run_paths.into_iter().next().ok_or_else(|| { + anyhow!( + "Xcode did not create an .xctestrun file in {}", + products_path.display() + ) + }) + } + + pub fn start_controlkit_device_runner( + &self, + request: &StartControlKitDeviceRunner, + ) -> Result { + ensure_path_exists(&request.xctestrun_path)?; + set_xctestrun_environment( + &request.xctestrun_path, + "CONTROLKIT_LISTEN_HOST", + &request.listen_host, + )?; + set_xctestrun_environment( + &request.xctestrun_path, + "CONTROLKIT_LISTEN_PORT", + &request.listen_port.to_string(), + )?; + + let tunnel_host = self.device_tunnel_address(&request.device_udid)?; + let log_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&request.log_path) + .with_context(|| format!("failed to open {}", request.log_path.display()))?; + let error_log_file = log_file + .try_clone() + .with_context(|| format!("failed to clone {}", request.log_path.display()))?; + + let child = Command::new(&self.xcodebuild_path) + .arg("test-without-building") + .arg("-xctestrun") + .arg(&request.xctestrun_path) + .arg("-destination") + .arg(format!("id={}", request.device_udid)) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(error_log_file)) + .spawn() + .with_context(|| { + format!( + "failed to start ControlKit runner for device {}", + request.device_udid + ) + })?; + + Ok(RunningControlKitDeviceRunner { + process: child, + tunnel_host, + listen_port: request.listen_port, + log_path: request.log_path.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct BuildControlKitDeviceRunner { + pub project_path: PathBuf, + pub scheme: String, + pub configuration: String, + pub device_udid: String, + pub derived_data_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct StartControlKitDeviceRunner { + pub device_udid: String, + pub xctestrun_path: PathBuf, + pub listen_host: String, + pub listen_port: u16, + pub log_path: PathBuf, +} + +#[derive(Debug)] +pub struct RunningControlKitDeviceRunner { + pub process: Child, + pub tunnel_host: String, + pub listen_port: u16, + pub log_path: PathBuf, +} + +fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> { + let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}"); + let set_status = Command::new("/usr/libexec/PlistBuddy") + .arg("-c") + .arg(format!("Set {key} {value}")) + .arg(path) + .status() + .with_context(|| format!("failed to update {}", path.display()))?; + + if set_status.success() { + return Ok(()); + } + + let add_status = Command::new("/usr/libexec/PlistBuddy") + .arg("-c") + .arg(format!("Add {key} string {value}")) + .arg(path) + .status() + .with_context(|| format!("failed to update {}", path.display()))?; + + if add_status.success() { + return Ok(()); + } + + Err(anyhow!( + "failed to set {name} in XCTest plan {}", + path.display() + )) +} + +fn parse_device_tunnel_address(output: &str) -> Option { + output + .lines() + .find_map(|line| line.split_once("Tunnel IP Address:")) + .map(|(_, address)| address.trim().to_string()) + .filter(|address| !address.is_empty()) } pub fn parse_simctl_devices(output: &str) -> Result> { @@ -567,4 +765,19 @@ mod tests { assert_eq!(devices[0].state, "Booted"); assert!(devices[0].is_booted()); } + + #[test] + fn formats_ipv6_controlkit_url() { + let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006); + assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006"); + } + + #[test] + fn parses_device_tunnel_address() { + let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n"; + assert_eq!( + parse_device_tunnel_address(output).as_deref(), + Some("fd55:33ce:ad87::1") + ); + } } From 6bd0c67bcbf380a34a6031a4b17ffe719227beaf Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:50:19 +0200 Subject: [PATCH 06/10] Add support for all Apple platforms --- crates/cli/src/cli/mod.rs | 6 +- crates/cli/src/controlkit.rs | 16 ++- crates/cli/src/mcp.rs | 3 +- crates/cli/src/mcp_xcrs.rs | 6 +- crates/xcrs/src/mcp.rs | 258 +++++++++++++++++++++++++++++++++-- crates/xcrs/src/xcrs.rs | 50 ++++++- docs/mcp.md | 37 ++++- 7 files changed, 351 insertions(+), 25 deletions(-) diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 21c3a43f..a2bdecf0 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -141,8 +141,10 @@ pub enum ControlKitCommands { }, #[clap(about = "Call a running ControlKit JSON-RPC method.")] Call { - #[arg(long)] - device_udid: String, + #[arg(long, required_unless_present = "host", conflicts_with = "host")] + device_udid: Option, + #[arg(long, conflicts_with = "device_udid")] + host: Option, #[arg(long, default_value_t = 12004)] port: u16, #[arg(long)] diff --git a/crates/cli/src/controlkit.rs b/crates/cli/src/controlkit.rs index ffd8d4ec..9af6f25f 100644 --- a/crates/cli/src/controlkit.rs +++ b/crates/cli/src/controlkit.rs @@ -83,15 +83,25 @@ pub async fn process_controlkit(command: ControlKitCommands) -> Result { - let tools = XcodeCommandLineTools::new(); - let tunnel_host = tools.device_tunnel_address(&device_udid)?; + let host = match (host, device_udid) { + (Some(host), None) => host, + (None, Some(device_udid)) => { + XcodeCommandLineTools::new().device_tunnel_address(&device_udid)? + } + _ => { + return Err(anyhow!( + "provide either --host or --device-udid for the ControlKit runner" + )); + } + }; let params = serde_json::from_str(¶ms) .with_context(|| format!("invalid JSON for --params: {params}"))?; - let result = ControlKit::with_host(&tunnel_host, port) + let result = ControlKit::with_host(&host, port) .call(&method, params) .await?; Ok(done_result(&serde_json::to_string_pretty(&result)?)) diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 71b511ea..33238498 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -932,7 +932,8 @@ impl ServerHandler for SmbMcpServer { "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \ `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \ the tenant/project context other tools default to, shared with the CLI's own \ - `smb tenant use` / `smb project use`.", + `smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \ + visionOS, watchOS, and macOS runners.", ) } } diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index 94c2a08a..5c736481 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -17,5 +17,9 @@ xcrs::xcrs_mcp_tools!( "smb_simulator_press_home", "smb_simulator_press_button", "smb_simulator_orientation_get", - "smb_simulator_orientation_set" + "smb_simulator_orientation_set", + "smb_controlkit_capabilities", + "smb_macos_click", + "smb_visionos_spatial_tap", + "smb_watchos_tap" ); diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 7581553f..a4c17231 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -172,6 +172,42 @@ pub struct SimulatorButtonArgs { pub button: String, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ControlKitEndpointArgs { + /// Exact simulator name. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host. Defaults to 127.0.0.1 for local runners. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ControlKitCoordinateArgs { + /// Exact simulator name. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host. Defaults to 127.0.0.1 for local runners. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Horizontal screen coordinate. + pub x: f32, + /// Vertical screen coordinate. + pub y: f32, +} + #[derive(Debug, Default)] pub struct XcrsMcpServer; @@ -200,7 +236,11 @@ macro_rules! xcrs_mcp_tools { $simulator_home_name:literal, $simulator_button_name:literal, $simulator_orientation_get_name:literal, - $simulator_orientation_set_name:literal + $simulator_orientation_set_name:literal, + $controlkit_capabilities_name:literal, + $macos_click_name:literal, + $visionos_spatial_tap_name:literal, + $watchos_tap_name:literal ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { @@ -248,9 +288,193 @@ macro_rules! xcrs_mcp_tools { )) } + fn controlkit_from_endpoint( + endpoint: &$crate::mcp::ControlKitEndpointArgs, + ) -> ::std::result::Result< + ($crate::ControlKit, Option<$crate::Simulator>), + ::rmcp::model::ErrorData, + > { + let simulator = match (&endpoint.simulator_udid, &endpoint.simulator_name) { + (Some(udid), _) => Some( + $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_udid(udid) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request( + error.to_string(), + None, + ) + })?, + ), + (None, Some(name)) => Some( + $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_name(name) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request( + error.to_string(), + None, + ) + })?, + ), + (None, None) => None, + }; + let host = endpoint.host.as_deref().unwrap_or("127.0.0.1"); + Ok(( + $crate::ControlKit::with_host( + host, + endpoint.controlkit_port.unwrap_or(12004), + ), + simulator, + )) + } + + fn endpoint_from_coordinate( + args: &$crate::mcp::ControlKitCoordinateArgs, + ) -> $crate::mcp::ControlKitEndpointArgs { + $crate::mcp::ControlKitEndpointArgs { + simulator_name: args.simulator_name.clone(), + simulator_udid: args.simulator_udid.clone(), + host: args.host.clone(), + controlkit_port: args.controlkit_port, + } + } + + #[::rmcp::tool( + name = $controlkit_capabilities_name, + description = "Read the platform and supported capabilities from a ControlKit runner. Use a simulator name/UDID for iOS, tvOS, watchOS, or visionOS; omit them for a local macOS runner." + )] + async fn controlkit_capabilities( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitEndpointArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (controlkit, simulator) = Self::controlkit_from_endpoint(&args)?; + let result = controlkit + .call("device.capabilities", ::serde_json::json!({})) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "capabilities": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $macos_click_name, + description = "Click a macOS ControlKit runner at screen coordinates. Omit simulator_name and simulator_udid for the local Mac." + )] + async fn macos_click( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.click", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Clicked ({}, {}).", + args.x, args.y + )), + ])) + } + + #[::rmcp::tool( + name = $visionos_spatial_tap_name, + description = "Perform a spatial tap on a visionOS ControlKit runner." + )] + async fn visionos_spatial_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.spatial.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Performed spatial tap at ({}, {}).", + args.x, args.y + )), + ])) + } + + #[::rmcp::tool( + name = $watchos_tap_name, + description = "Tap a watchOS ControlKit runner at screen coordinates." + )] + async fn watchos_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Tapped ({}, {}).", + args.x, args.y + )), + ])) + } + #[::rmcp::tool( name = $simulator_list_name, - description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state." + description = "List all Apple simulator devices known to Xcode, including their platform, runtime, UDID, availability, and current state." )] async fn simulator_list( &self, @@ -271,7 +495,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_find_name, - description = "Find an iOS simulator by its exact name and return its details as JSON." + description = "Find an Apple simulator by its exact name and return its details as JSON." )] async fn simulator_find( &self, @@ -335,7 +559,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_screenshot_name, - description = "Capture a PNG screenshot from an iOS simulator." + description = "Capture a PNG screenshot from an Apple simulator." )] async fn simulator_screenshot( &self, @@ -363,7 +587,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_launch_app_name, - description = "Launch an installed app on an iOS simulator by bundle identifier." + description = "Launch an installed app on an Apple simulator by bundle identifier." )] async fn simulator_launch_app( &self, @@ -398,7 +622,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_terminate_app_name, - description = "Terminate an installed app on an iOS simulator by bundle identifier." + description = "Terminate an installed app on an Apple simulator by bundle identifier." )] async fn simulator_terminate_app( &self, @@ -433,7 +657,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_open_url_name, - description = "Open a URL or custom URL scheme on an iOS simulator." + description = "Open a URL or custom URL scheme on an Apple simulator." )] async fn simulator_open_url( &self, @@ -468,7 +692,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_ui_dump_name, - description = "Return the accessibility UI hierarchy from the foreground iOS app through ControlKit." + description = "Return the accessibility UI hierarchy from the foreground Apple app through ControlKit." )] async fn simulator_ui_dump( &self, @@ -502,7 +726,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_list_elements_name, - description = "List actionable accessibility elements and coordinates from the foreground iOS app through ControlKit." + description = "List actionable accessibility elements and coordinates from the foreground Apple app through ControlKit." )] async fn simulator_list_elements( &self, @@ -537,7 +761,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_tap_name, - description = "Tap the iOS simulator screen at the given coordinates through ControlKit." + description = "Tap an Apple simulator screen at the given coordinates through ControlKit." )] async fn simulator_tap( &self, @@ -574,7 +798,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_text_name, - description = "Type text into the focused iOS simulator field through ControlKit." + description = "Type text into the focused Apple simulator field through ControlKit." )] async fn simulator_text( &self, @@ -608,7 +832,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_swipe_name, - description = "Swipe between two screen coordinates on an iOS simulator through ControlKit." + description = "Swipe between two screen coordinates on an Apple simulator through ControlKit." )] async fn simulator_swipe( &self, @@ -650,7 +874,7 @@ macro_rules! xcrs_mcp_tools { #[::rmcp::tool( name = $simulator_home_name, - description = "Press the iOS simulator Home button through ControlKit." + description = "Press the iOS Home button through ControlKit." )] async fn simulator_home( &self, @@ -833,7 +1057,11 @@ xcrs_mcp_tools!( "xcrs_simulator_press_home", "xcrs_simulator_press_button", "xcrs_simulator_orientation_get", - "xcrs_simulator_orientation_set" + "xcrs_simulator_orientation_set", + "xcrs_controlkit_capabilities", + "xcrs_macos_click", + "xcrs_visionos_spatial_tap", + "xcrs_watchos_tap" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] @@ -846,7 +1074,7 @@ impl ServerHandler for XcrsMcpServer { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(implementation) .with_instructions( - "xcrs exposes Xcode command line tools for iOS simulators and app testing.", + "xcrs exposes Xcode command line tools and ControlKit runners for Apple platform testing.", ) } } diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index 53d0d647..c7e957d3 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -217,6 +217,8 @@ pub enum ApplePlatform { Ios, #[serde(rename = "tvOS")] Tvos, + #[serde(rename = "macOS")] + Macos, #[serde(rename = "watchOS")] Watchos, #[serde(rename = "visionOS")] @@ -231,9 +233,12 @@ impl ApplePlatform { Self::Ios } else if runtime_identifier.contains(".tvOS-") { Self::Tvos + } else if runtime_identifier.contains(".macOS-") { + Self::Macos } else if runtime_identifier.contains(".watchOS-") { Self::Watchos - } else if runtime_identifier.contains(".visionOS-") { + } else if runtime_identifier.contains(".visionOS-") || runtime_identifier.contains(".xrOS-") + { Self::Visionos } else { Self::Unknown @@ -766,6 +771,49 @@ mod tests { assert!(devices[0].is_booted()); } + #[test] + fn parses_xros_simulator_runtime() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.xrOS-26-5": [ + { + "udid": "00000000-0000-0000-0000-000000000001", + "isAvailable": true, + "state": "Shutdown", + "name": "Apple Vision Pro" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Visionos); + assert_eq!(devices[0].name, "Apple Vision Pro"); + } + + #[test] + fn parses_macos_runtime() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.macOS-26-0": [ + { + "udid": "00000000-0000-0000-0000-000000000002", + "isAvailable": true, + "state": "Booted", + "name": "Mac" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Macos); + } + #[test] fn formats_ipv6_controlkit_url() { let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006); diff --git a/docs/mcp.md b/docs/mcp.md index c07f725e..60d0a68a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,8 +4,9 @@ The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI assistant or agent — Claude Desktop, Claude Code, Cursor, or any other MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and Auth apps directly, without you leaving the chat to run `smb` commands by hand. -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 30 -tools covering the account, project, tenant, Mail, and Auth surfaces. +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 50 +tools covering the account, project, tenant, Mail, Auth, simulator, and +ControlKit runner surfaces. This page is the setup guide and the full tool reference. For how `--mcp` compares to the CLI's other two interfaces (headless and `--tui`), see @@ -212,6 +213,38 @@ users; it belongs to a project. | `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. | | `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** | +### ControlKit runners + +The `smb` MCP server also exposes local Xcode simulator and ControlKit runner +tools. Simulator arguments accept either `simulator_name` or `simulator_udid`; +ControlKit arguments default to `127.0.0.1:12004`. For a local macOS runner, +omit the simulator fields and use the optional `host` and `controlkit_port` +fields. + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `smb_simulator_list` | _(none)_ | All Apple simulator devices known to Xcode. | +| `smb_simulator_find` | `name` | A simulator's details. | +| `smb_controlkit_capabilities` | simulator target or optional `host`, `controlkit_port` | Runner platform and capabilities. | +| `smb_macos_click` | `x`, `y`, plus optional endpoint fields | A pointer click on a macOS runner. | +| `smb_visionos_spatial_tap` | `x`, `y`, plus optional endpoint fields | A spatial tap on a visionOS runner. | +| `smb_watchos_tap` | `x`, `y`, plus optional endpoint fields | A touch tap on a watchOS runner. | +| `smb_simulator_tap` | simulator target, `x`, `y` | A touch tap through ControlKit. | +| `smb_simulator_type_text` | simulator target, `text` | Text input through ControlKit. | +| `smb_simulator_swipe` | simulator target, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. | +| `smb_simulator_press_button` | simulator target, `button` | A supported remote-button press. | +| `smb_simulator_press_home` | simulator target | An iOS Home-button press. | +| `smb_simulator_orientation_get`/`set` | simulator target; `orientation` for `set` | Current or updated iOS orientation. | +| `smb_simulator_launch_app`/`terminate_app` | simulator target, `bundle_id` | App lifecycle confirmation. | +| `smb_simulator_open_url` | simulator target, `url` | URL-open confirmation. | +| `smb_simulator_ui_dump` | simulator target | Accessibility UI hierarchy. | +| `smb_simulator_list_elements` | simulator target | Actionable accessibility elements. | +| `smb_simulator_screenshot` | simulator target | A PNG image. | +| `smb_ios_app_test` | simulator target, `app_path`, `bundle_id` | Installed/launched app details. | + +The standalone `xcrs --mcp` server exposes the same runner surface with the +`xcrs_` prefix instead of `smb_`. + ## Safety: tools run without confirmation The CLI's own `project delete`, `tenant delete`, and similar commands ask for From 3d9545a174930f453012563fadcb83595d150a10 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:59:28 +0200 Subject: [PATCH 07/10] Update docs --- docs/controlkit.md | 97 +++++++++++++++++++++ docs/interfaces.md | 7 +- docs/mcp.md | 2 + website/.gitignore | 1 + website/content/developer/cli/index.mdx | 7 +- website/scripts/generate-developer-docs.mjs | 9 +- 6 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 docs/controlkit.md diff --git a/docs/controlkit.md b/docs/controlkit.md new file mode 100644 index 00000000..73539373 --- /dev/null +++ b/docs/controlkit.md @@ -0,0 +1,97 @@ +# ControlKit runners + +ControlKit is the native XCTest runner used by the CLI's Apple-platform MCP +tools. The public runner project is +[`xcrs-controlkit`](https://github.com/ondeinference/xcrs-controlkit). It uses +one Xcode project generated by XcodeGen, with platform-specific targets and +schemes: + +| Platform | Scheme | App product | +| --- | --- | --- | +| iOS / iPadOS | `ControlKit-iOS` | `ControlKit.app` | +| tvOS | `ControlKit-tvOS` | `ControlKit.app` | +| visionOS | `ControlKit-visionOS` | `ControlKit.app` | +| watchOS | `ControlKit-watchOS` | `ControlKit.app` | +| macOS | `ControlKit-macOS` | `ControlKit.app` | + +The scheme and target names keep their platform suffixes so Xcode can select +the correct SDK, while each built app is named `ControlKit`. + +## Build a runner + +Install XcodeGen once, clone the runner project, and generate the Xcode +project: + +```sh +brew install xcodegen +cd /Runners +xcodegen generate +``` + +Build the simulator runner for the platform you need: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'generic/platform= Simulator' \ + build-for-testing +``` + +Replace `` with `iOS`, `tvOS`, `watchOS`, or `visionOS`. macOS is +built with `-destination 'platform=macOS'` and runs on the local Mac rather +than a simulator. + +## Start the runner + +Boot a compatible simulator and run the UI-test host: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + test-without-building +``` + +The shared XCTest runner starts a local ControlKit JSON-RPC server on +`127.0.0.1:12004`. Keep that test process running while calling the MCP tools. +The macOS UI-test target includes the network-server entitlement required to +bind the local port. + +## Connect through `smb --mcp` + +Start the CLI MCP server: + +```sh +smb --mcp +``` + +The runner tools accept either `simulator_name` or `simulator_udid`. The +standalone `xcrs --mcp` server exposes the same tools with the `xcrs_` prefix. + +| Tool | Runner | Purpose | +| --- | --- | --- | +| `smb_controlkit_capabilities` | All platforms | Read platform and capability information. | +| `smb_macos_click` | macOS | Click at screen coordinates. | +| `smb_visionos_spatial_tap` | visionOS | Perform a spatial tap. | +| `smb_watchos_tap` | watchOS | Perform a touch tap. | +| `smb_simulator_tap` | iOS / tvOS | Perform a touch tap. | +| `smb_simulator_type_text` | iOS / tvOS | Type into the focused field. | +| `smb_simulator_swipe` | iOS / tvOS | Swipe between coordinates. | +| `smb_simulator_screenshot` | All simulator runners | Capture a PNG screenshot. | +| `smb_simulator_ui_dump` | All UI-test runners | Read the accessibility hierarchy. | + +For a local macOS runner, omit simulator fields and pass `host` and +`controlkit_port` when needed: + +```json +{ + "host": "127.0.0.1", + "controlkit_port": 12004, + "x": 400, + "y": 300 +} +``` + +Platform-specific operations intentionally reject unsupported input. For +example, visionOS rejects raw touch, watchOS rejects pointer and spatial input, +and macOS rejects touch and Home-button requests. diff --git a/docs/interfaces.md b/docs/interfaces.md index 3c9c0bb4..3094d9c6 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -50,8 +50,8 @@ dialog under `--tui`; in the headless interface they ask inline instead. `smb --mcp` starts an MCP (Model Context Protocol) server that speaks JSON-RPC over stdio. Instead of running a single command and exiting, it stays -up and exposes smbCloud operations — 30 tools spanning accounts, projects, -tenants, Mail, and Auth — as MCP **tools** that an MCP-capable client +up and exposes 50 tools spanning accounts, projects, tenants, Mail, Auth, +simulators, and ControlKit runners as MCP **tools** that an MCP-capable client (Claude Desktop, Claude Code, Cursor, or any other assistant/agent) can call. The subcommand is ignored in this mode. @@ -61,4 +61,5 @@ including the `*_delete` tools, which apply immediately with no confirmation prompt. For the client setup guide and the full tool reference, see -[MCP Server](./mcp.md). +[MCP Server](./mcp.md). For running the native Apple-platform runners, see +[ControlKit runners](./controlkit.md). diff --git a/docs/mcp.md b/docs/mcp.md index 60d0a68a..25a366d7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -13,6 +13,8 @@ compares to the CLI's other two interfaces (headless and `--tui`), see [Interfaces](./interfaces.md). The server is also listed in the official MCP Registry as `io.github.smbcloudXYZ/smbcloud-cli`, so clients that browse the registry can install it for you — see [MCP Registry](./mcp-registry.md). +For building and starting the native runners, see +[ControlKit runners](./controlkit.md). ## Prerequisites diff --git a/website/.gitignore b/website/.gitignore index 30e34054..84b0f05c 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -44,6 +44,7 @@ yarn-error.log* /content/developer/cli/pypi.mdx /content/developer/cli/interfaces.mdx /content/developer/cli/mcp.mdx +/content/developer/cli/controlkit.mdx /content/developer/cli/ci.mdx /content/developer/contributing/_meta.js /content/developer/contributing/debugging.mdx diff --git a/website/content/developer/cli/index.mdx b/website/content/developer/cli/index.mdx index 29a18179..93a33ab2 100644 --- a/website/content/developer/cli/index.mdx +++ b/website/content/developer/cli/index.mdx @@ -7,8 +7,8 @@ description: Install the smbCloud CLI (smb), run it headless/--tui/--mcp, and co # CLI `smb` is the smbCloud command-line tool. You use it to log in and to manage and -deploy your smbCloud resources — projects, tenants, Mail, and Auth — from the -terminal, a script, or an MCP-connected AI agent. +deploy your smbCloud resources — projects, tenants, Mail, Auth, simulators, and +ControlKit runners — from the terminal, a script, or an MCP-connected AI agent. ```text Usage: smb [OPTIONS] [COMMAND] @@ -62,7 +62,8 @@ You can also download a binary for any release directly from the | Guide | What it covers | | --- | --- | | [Interfaces](/developer/cli/interfaces) | The three ways `smb` runs — headless, `--tui`, and `--mcp` — and when to use each. | -| [MCP Server](/developer/cli/mcp) | Connect Claude Desktop, Claude Code, Cursor, or any MCP client to `smb --mcp`; the full 30-tool reference. | +| [MCP Server](/developer/cli/mcp) | Connect Claude Desktop, Claude Code, Cursor, or any MCP client to `smb --mcp`; the full tool reference. | +| [ControlKit runners](/developer/cli/controlkit) | Build native iOS, tvOS, visionOS, watchOS, and macOS runners and expose them through the MCP servers. | | [CI](/developer/cli/ci) | Run `smb` non-interactively with `--ci` and deploy from GitHub Actions. | import { Callout } from 'nextra/components' diff --git a/website/scripts/generate-developer-docs.mjs b/website/scripts/generate-developer-docs.mjs index 3b63fa24..f2fb7873 100644 --- a/website/scripts/generate-developer-docs.mjs +++ b/website/scripts/generate-developer-docs.mjs @@ -69,7 +69,14 @@ const MANIFEST = { slug: 'mcp', title: 'MCP Server', description: - 'Run the smbCloud CLI as an MCP server and connect Claude Desktop, Claude Code, Cursor, or any MCP client to manage your projects, tenants, Mail, and Auth apps. Setup guide plus the full 30-tool reference.' + 'Run the smbCloud CLI as an MCP server and connect Claude Desktop, Claude Code, Cursor, or any MCP client to manage smbCloud resources, simulators, and ControlKit runners. Setup guide plus the full tool reference.' + }, + { + src: 'controlkit.md', + slug: 'controlkit', + title: 'ControlKit runners', + description: + 'Build and connect the native ControlKit runners for iOS, tvOS, visionOS, watchOS, and macOS through the smbCloud and xcrs MCP servers.' }, { src: 'ci.md', From b0eaddd511db887bfa37426e13d7fbb149a5f740 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:24:31 +0200 Subject: [PATCH 08/10] Use unify versions --- .agents/AGENTS.md | 2 +- .github/workflows/release-mcp-registry.yml | 48 ++- Cargo.lock | 2 +- Cargo.toml | 24 +- Makefile | 14 +- crates/xcrs/Cargo.toml | 2 +- crates/xcrs/README.md | 18 + docs/mcp-registry.md | 32 +- npm/smbcloud-cli/package-lock.json | 14 +- npm/smbcloud-cli/package.json | 12 +- nuget/smbcloud-cli/SmbCloud.Cli.csproj | 2 +- scripts/check-release-versions.mjs | 102 +++++ scripts/sync-release-version.mjs | 105 +++++ sdk/gems/email/Cargo.lock | 427 ++++++++------------- sdk/gems/email/Gemfile.lock | 2 +- sdk/gems/email/ext/email/Cargo.toml | 2 +- sdk/gems/email/lib/email/version.rb | 2 +- server-xcrs.json | 52 +++ 18 files changed, 553 insertions(+), 309 deletions(-) create mode 100644 crates/xcrs/README.md create mode 100644 scripts/check-release-versions.mjs create mode 100644 server-xcrs.json diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index d1d0343f..4d3aeb16 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -101,7 +101,7 @@ make patch | make minor | make major | make custom VERSION=0.x.y It commits `Release ` and tags `v` locally; pushing is manual. Crate publishing is separate and manual (`cargo workspaces publish`, see `docs/development.md`). The per-target release workflows (`release-*.yml`) build the distributable artifacts. -`server.json` (the MCP Registry listing) is version-synced by the same Makefile step, and `release-nuget.yml` triggers `release-mcp-registry.yml` once its publish job succeeds — the listing needs both npm and NuGet live at that version, and the registry validates its metadata against the real packages. See `docs/mcp-registry.md`. +`server.json` and `server-xcrs.json` (the MCP Registry listings) are version-synced by the same Makefile step, and `release-nuget.yml` triggers `release-mcp-registry.yml` once its publish job succeeds — the listings need the npm, NuGet, and Cargo packages live at their recorded versions, and the registry validates their metadata against the real packages. See `docs/mcp-registry.md`. --- diff --git a/.github/workflows/release-mcp-registry.yml b/.github/workflows/release-mcp-registry.yml index 9780d038..68d38d63 100644 --- a/.github/workflows/release-mcp-registry.yml +++ b/.github/workflows/release-mcp-registry.yml @@ -15,7 +15,7 @@ permissions: jobs: publish: - name: Publish server.json to the MCP Registry + name: Publish MCP servers to the MCP Registry runs-on: ubuntu-latest steps: - name: Checkout @@ -44,14 +44,28 @@ jobs: # NuGet proves ownership through an `mcp-name:` marker in the package README. grep -q "mcp-name: ${server_name}" nuget/smbcloud-cli/README.md - - name: Set the version in server.json + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + grep -q "mcp-name: ${xcrs_server_name}" crates/xcrs/README.md + + - name: Set release versions in server metadata shell: bash run: | jq --arg v "${RELEASE_VERSION}" \ '.version = $v | .packages |= map(.version = $v)' \ server.json > server.tmp mv server.tmp server.json + + xcrs_version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/xcrs/Cargo.toml | head -n 1)" + if [ "${xcrs_version}" != "${RELEASE_VERSION}" ]; then + echo "xcrs version ${xcrs_version} does not match release version ${RELEASE_VERSION}." >&2 + exit 1 + fi + jq --arg v "${xcrs_version}" \ + '.version = $v | .packages |= map(.version = $v)' \ + server-xcrs.json > server-xcrs.tmp + mv server-xcrs.tmp server-xcrs.json cat server.json + cat server-xcrs.json - name: Verify the published packages carry the ownership marker shell: bash @@ -109,6 +123,29 @@ jobs: fi rm package.nupkg + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + xcrs_version="$(jq -r '.version' server-xcrs.json)" + for attempt in $(seq 1 20); do + if curl -fsSL "https://crates.io/api/v1/crates/xcrs/${xcrs_version}" >/dev/null; then + echo "xcrs ${xcrs_version} is indexed on crates.io." + break + fi + if [ "${attempt}" -eq 20 ]; then + echo "xcrs ${xcrs_version} is still not indexed on crates.io after 10 minutes." >&2 + exit 1 + fi + echo "Waiting for crates.io to index xcrs ${xcrs_version} (attempt ${attempt}/20)..." + sleep 30 + done + + curl -fsSL -o xcrs.crate \ + "https://crates.io/api/v1/crates/xcrs/${xcrs_version}/download" + if ! tar -xOf xcrs.crate "xcrs-${xcrs_version}/README.md" | grep -q "mcp-name: ${xcrs_server_name}"; then + echo "xcrs ${xcrs_version} README is missing the 'mcp-name: ${xcrs_server_name}' marker." >&2 + exit 1 + fi + rm xcrs.crate + - name: Install mcp-publisher shell: bash run: | @@ -120,10 +157,15 @@ jobs: - name: Publish shell: bash - run: ./mcp-publisher publish + run: | + ./mcp-publisher publish + cp server-xcrs.json server.json + ./mcp-publisher publish - name: Verify the server is listed shell: bash run: | server_name="$(jq -r '.name' server.json)" curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq . + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${xcrs_server_name}" | jq . diff --git a/Cargo.lock b/Cargo.lock index 88119639..e47420d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3996,7 +3996,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xcrs" -version = "0.1.0" +version = "0.4.13" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 10b2e4bc..4662510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,17 +29,17 @@ schemars = "1" serde = "1" serde_json = "1.0.82" serde_repr = "0.1" -smbcloud-auth = { version = "0.4.9", path = "crates/smbcloud-auth" } -smbcloud-auth-sdk = { version = "0.4.9", path = "crates/smbcloud-auth-sdk" } -smbcloud-deploy = { version = "0.4.9", path = "crates/smbcloud-deploy" } -smbcloud-email-sdk = { version = "0.4.9", path = "crates/smbcloud-email-sdk" } -smbcloud-gresiq-sdk = { version = "0.4.9", path = "crates/smbcloud-gresiq-sdk" } -smbcloud-mail = { version = "0.4.9", path = "crates/smbcloud-mail" } -smbcloud-model = { version = "0.4.9", path = "crates/smbcloud-model" } -smbcloud-network = { version = "0.4.9", path = "crates/smbcloud-network" } -smbcloud-networking = { version = "0.4.9", path = "crates/smbcloud-networking" } -smbcloud-networking-project = { version = "0.4.9", path = "crates/smbcloud-networking-project" } -smbcloud-utils = { version = "0.4.9", path = "crates/smbcloud-utils" } +smbcloud-auth = { version = "0.4.13", path = "crates/smbcloud-auth" } +smbcloud-auth-sdk = { version = "0.4.13", path = "crates/smbcloud-auth-sdk" } +smbcloud-deploy = { version = "0.4.13", path = "crates/smbcloud-deploy" } +smbcloud-email-sdk = { version = "0.4.13", path = "crates/smbcloud-email-sdk" } +smbcloud-gresiq-sdk = { version = "0.4.13", path = "crates/smbcloud-gresiq-sdk" } +smbcloud-mail = { version = "0.4.13", path = "crates/smbcloud-mail" } +smbcloud-model = { version = "0.4.13", path = "crates/smbcloud-model" } +smbcloud-network = { version = "0.4.13", path = "crates/smbcloud-network" } +smbcloud-networking = { version = "0.4.13", path = "crates/smbcloud-networking" } +smbcloud-networking-project = { version = "0.4.13", path = "crates/smbcloud-networking-project" } +smbcloud-utils = { version = "0.4.13", path = "crates/smbcloud-utils" } spinners = "4.1.0" strum = "0.27" strum_macros = "0.27" @@ -56,4 +56,4 @@ tsync = "2" uniffi = "0.31" url-builder = "0.1.1" wasm-bindgen = "0.2" -xcrs = { version = "0.1.0", path = "crates/xcrs" } +xcrs = { version = "0.4.13", path = "crates/xcrs" } diff --git a/Makefile b/Makefile index 402f94e2..1befe5bc 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: help release patch minor major custom sync-release-metadata regenerate-release-lockfiles +.PHONY: help release patch minor major custom sync-release-metadata regenerate-release-lockfiles check-release-versions help: @echo "Release commands:" @echo " make patch" @echo " make minor" @echo " make major" - @echo " make custom VERSION=0.3.40" + @echo " make custom VERSION=0.4.14" release: @test -n "$(BUMP)" || (echo "BUMP is required" && exit 1) @@ -19,6 +19,7 @@ release: fi @$(MAKE) sync-release-metadata @$(MAKE) regenerate-release-lockfiles + @$(MAKE) check-release-versions @release_version=$$(sed -n 's/^version = "\(.*\)"/\1/p' crates/cli/Cargo.toml | head -n 1); \ if [ -z "$$release_version" ]; then echo "Unable to determine release version from crates/cli/Cargo.toml"; exit 1; fi; \ git add -A; \ @@ -32,9 +33,14 @@ sync-release-metadata: regenerate-release-lockfiles: @cargo generate-lockfile --manifest-path sdk/gems/auth/Cargo.toml + @cargo generate-lockfile --manifest-path sdk/gems/email/Cargo.toml @cargo generate-lockfile --manifest-path sdk/gems/model/Cargo.toml - @bundle lock --gemfile sdk/gems/auth/Gemfile - @bundle lock --gemfile sdk/gems/model/Gemfile + @BUNDLE_GEMFILE=sdk/gems/auth/Gemfile bundle lock + @BUNDLE_GEMFILE=sdk/gems/email/Gemfile bundle lock + @BUNDLE_GEMFILE=sdk/gems/model/Gemfile bundle lock + +check-release-versions: + @node ./scripts/check-release-versions.mjs patch: @$(MAKE) release BUMP=patch diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml index b6b5295f..98cdc716 100644 --- a/crates/xcrs/Cargo.toml +++ b/crates/xcrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xcrs" -version = "0.1.0" +version = "0.4.13" edition = "2021" authors = ["Seto Elkahfi "] description = "Rust abstraction for Xcode command line tools." diff --git a/crates/xcrs/README.md b/crates/xcrs/README.md new file mode 100644 index 00000000..d84b0f58 --- /dev/null +++ b/crates/xcrs/README.md @@ -0,0 +1,18 @@ +# XCRS + +Rust-native Xcode command-line and Apple simulator automation tools with an +MCP server for simulator and ControlKit workflows. + +MCP Registry name: `mcp-name: io.github.smbcloudXYZ/xcrs` + +## Run the MCP server + +```sh +cargo install xcrs +xcrs --mcp +``` + +## Repository + +Source and documentation are available in the +[smbcloud-cli repository](https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs). diff --git a/docs/mcp-registry.md b/docs/mcp-registry.md index 2d1e2fdd..3830a46b 100644 --- a/docs/mcp-registry.md +++ b/docs/mcp-registry.md @@ -1,10 +1,13 @@ # MCP Registry -The smbCloud CLI's MCP server is listed in the [official MCP Registry](https://registry.modelcontextprotocol.io) -as **`io.github.smbcloudXYZ/smbcloud-cli`**. The registry is a metadata index — -it doesn't host binaries — so the listing points at packages we already publish -to npm and NuGet, and clients that browse the registry can offer a one-click -install of `smb --mcp`. +The repository publishes two MCP servers to the [official MCP Registry](https://registry.modelcontextprotocol.io): + +- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud resources, simulators, and ControlKit runners. +- **`io.github.smbcloudXYZ/xcrs`** — the standalone Rust `xcrs --mcp` server for Xcode and Apple simulator tooling. + +The registry is a metadata index — it doesn't host binaries. The smbCloud +listing points at packages published to npm and NuGet, while the standalone +XCRS listing points at the `xcrs` crate on crates.io. For running and configuring the server itself, see [MCP Server](./mcp.md). @@ -12,9 +15,11 @@ For running and configuring the server itself, see [MCP Server](./mcp.md). | Piece | Where | | --- | --- | -| Server metadata | [`server.json`](../server.json) at the repo root | +| smbCloud server metadata | [`server.json`](../server.json) at the repo root | +| XCRS server metadata | [`server-xcrs.json`](../server-xcrs.json) at the repo root | | npm ownership proof | `mcpName` in the generated `@smbcloud/cli` `package.json` (`npm/scripts/render-main-package.cjs`) | | NuGet ownership proof | `` in `nuget/smbcloud-cli/README.md` | +| Cargo ownership proof | Visible `mcp-name: ...` text in `crates/xcrs/README.md` | | Publishing | `.github/workflows/release-mcp-registry.yml` | The registry checks each listed package for a marker naming the server. If the @@ -60,9 +65,9 @@ documented in [Install](./cli-install.md). ## Releasing a new version -`server.json` carries the version twice — once for the server, once per package -— and both are updated by `make patch | minor | major` along with the rest of -the release metadata (`scripts/sync-release-version.mjs`). +Each server metadata file carries its version twice — once for the server, once +per package — and both are updated by `make patch | minor | major` along with +the rest of the release metadata (`scripts/sync-release-version.mjs`). Publishing is chained off the release: pushing a `v*` tag runs the crates.io workflow, which fans out to the distribution workflows, and **NuGet CLI @@ -83,10 +88,11 @@ the tag: gh workflow run release-mcp-registry.yml -f tag=v0.4.13 ``` -Confirm the listing afterwards: +Confirm both listings afterwards: ```sh curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.smbcloudXYZ/smbcloud-cli" +curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.smbcloudXYZ/xcrs" ``` ## Publishing by hand @@ -99,5 +105,7 @@ mcp-publisher login github # device flow, needs push access to smbcloudXY mcp-publisher publish # reads ./server.json ``` -Versions are immutable — republishing the same version is rejected. Fixing a -bad listing means shipping a new patch version. +The release workflow publishes `server.json`, then temporarily swaps in +`server-xcrs.json` and publishes the standalone entry as well. Versions are +immutable — republishing the same version is rejected. Fixing a bad listing +means shipping a new patch version. diff --git a/npm/smbcloud-cli/package-lock.json b/npm/smbcloud-cli/package-lock.json index c9a62213..b4b58903 100644 --- a/npm/smbcloud-cli/package-lock.json +++ b/npm/smbcloud-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "license": "Apache-2.0", "bin": { "smb": "lib/index.js" @@ -19,11 +19,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "0.4.7", - "@smbcloud/cli-darwin-x64": "0.4.7", - "@smbcloud/cli-linux-x64": "0.4.7", - "@smbcloud/cli-windows-arm64": "0.4.7", - "@smbcloud/cli-windows-x64": "0.4.7" + "@smbcloud/cli-darwin-arm64": "0.4.13", + "@smbcloud/cli-darwin-x64": "0.4.13", + "@smbcloud/cli-linux-x64": "0.4.13", + "@smbcloud/cli-windows-arm64": "0.4.13", + "@smbcloud/cli-windows-x64": "0.4.13" } }, "node_modules/@eslint-community/eslint-utils": { diff --git a/npm/smbcloud-cli/package.json b/npm/smbcloud-cli/package.json index 83e6156b..9207e553 100644 --- a/npm/smbcloud-cli/package.json +++ b/npm/smbcloud-cli/package.json @@ -1,6 +1,6 @@ { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "keywords": [ "smbcloud", "cli", @@ -58,10 +58,10 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "0.4.7", - "@smbcloud/cli-darwin-x64": "0.4.7", - "@smbcloud/cli-linux-x64": "0.4.7", - "@smbcloud/cli-windows-arm64": "0.4.7", - "@smbcloud/cli-windows-x64": "0.4.7" + "@smbcloud/cli-darwin-arm64": "0.4.13", + "@smbcloud/cli-darwin-x64": "0.4.13", + "@smbcloud/cli-linux-x64": "0.4.13", + "@smbcloud/cli-windows-arm64": "0.4.13", + "@smbcloud/cli-windows-x64": "0.4.13" } } diff --git a/nuget/smbcloud-cli/SmbCloud.Cli.csproj b/nuget/smbcloud-cli/SmbCloud.Cli.csproj index f6d7a3d0..e17bf054 100644 --- a/nuget/smbcloud-cli/SmbCloud.Cli.csproj +++ b/nuget/smbcloud-cli/SmbCloud.Cli.csproj @@ -7,7 +7,7 @@ true smb SmbCloud.Cli - 0.0.0-local + 0.4.13 $(PackageVersion) Seto Elkahfi smbCloud command line interface. diff --git a/scripts/check-release-versions.mjs b/scripts/check-release-versions.mjs new file mode 100644 index 00000000..188173fd --- /dev/null +++ b/scripts/check-release-versions.mjs @@ -0,0 +1,102 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const expectedVersion = readFileSync(resolve(repoRoot, "crates/cli/Cargo.toml"), "utf8") + .match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + +if (!expectedVersion) { + throw new Error("Unable to determine the release version from crates/cli/Cargo.toml"); +} + +const mismatches = []; + +function read(path) { + return readFileSync(resolve(repoRoot, path), "utf8"); +} + +function check(path, actualVersion) { + if (actualVersion !== expectedVersion) { + mismatches.push(`${path}: ${actualVersion ?? "missing"} (expected ${expectedVersion})`); + } +} + +function cargoPackageVersion(path) { + return read(path).match(/^\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m)?.[1]; +} + +for (const directory of ["crates", "sdk/gems"]) { + const directoryPath = resolve(repoRoot, directory); + for (const entry of readdirSync(directoryPath, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const candidates = [ + `${directory}/${entry.name}/Cargo.toml`, + ...readdirSync(resolve(directoryPath, entry.name), { withFileTypes: true }) + .filter((nestedEntry) => nestedEntry.isDirectory()) + .map((nestedEntry) => `${directory}/${entry.name}/${nestedEntry.name}/Cargo.toml`), + ]; + + for (const candidate of candidates) { + if (existsSync(resolve(repoRoot, candidate))) { + const version = cargoPackageVersion(candidate); + if (version) { + check(candidate, version); + } + } + } + } +} + +for (const match of read("Cargo.toml").matchAll( + /^([\w-]+)\s*=\s*\{\s*version\s*=\s*"([^"]+)"\s*,\s*path\s*=\s*"crates\//gm, +)) { + check(`Cargo.toml workspace dependency ${match[1]}`, match[2]); +} + +for (const path of [ + "sdk/gems/auth/lib/auth/version.rb", + "sdk/gems/email/lib/email/version.rb", + "sdk/gems/model/lib/model/version.rb", +]) { + check(path, read(path).match(/VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1]); +} + +const npmPackage = JSON.parse(read("npm/smbcloud-cli/package.json")); +check("npm/smbcloud-cli/package.json", npmPackage.version); + +const sdkNpmPackage = JSON.parse(read("sdk/npm/smbcloud-auth/package.json")); +check("sdk/npm/smbcloud-auth/package.json", sdkNpmPackage.version); + +const npmLock = JSON.parse(read("npm/smbcloud-cli/package-lock.json")); +check("npm/smbcloud-cli/package-lock.json", npmLock.version); +check("npm/smbcloud-cli/package-lock.json packages root", npmLock.packages?.[""]?.version); + +for (const path of ["server.json", "server-xcrs.json"]) { + const server = JSON.parse(read(path)); + check(path, server.version); + for (const [index, packageEntry] of (server.packages ?? []).entries()) { + check(`${path} package ${index + 1}`, packageEntry.version); + } +} + +check( + "nuget/smbcloud-cli/SmbCloud.Cli.csproj", + read("nuget/smbcloud-cli/SmbCloud.Cli.csproj").match(/([^<]+)<\/PackageVersion>/)?.[1], +); +check( + "sdk/gems/email/Gemfile.lock", + read("sdk/gems/email/Gemfile.lock").match(/smbcloud-email \(([^)]+)\)/)?.[1], +); + +if (mismatches.length > 0) { + console.error(`Release version mismatch detected for ${expectedVersion}:`); + for (const mismatch of mismatches) { + console.error(`- ${mismatch}`); + } + process.exit(1); +} + +console.log(`All release artifacts use version ${expectedVersion}.`); diff --git a/scripts/sync-release-version.mjs b/scripts/sync-release-version.mjs index e5fead4a..9f25f8f5 100644 --- a/scripts/sync-release-version.mjs +++ b/scripts/sync-release-version.mjs @@ -3,12 +3,19 @@ import { resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, ".."); const cliCargoTomlPath = resolve(repoRoot, "crates/cli/Cargo.toml"); +const npmCliPackageJsonPath = resolve(repoRoot, "npm/smbcloud-cli/package.json"); +const npmCliPackageLockPath = resolve(repoRoot, "npm/smbcloud-cli/package-lock.json"); +const nugetCliProjectPath = resolve(repoRoot, "nuget/smbcloud-cli/SmbCloud.Cli.csproj"); const sdkNpmPackageJsonPath = resolve(repoRoot, "sdk/npm/smbcloud-auth/package.json"); const authGemVersionPath = resolve(repoRoot, "sdk/gems/auth/lib/auth/version.rb"); const authGemCargoTomlPath = resolve(repoRoot, "sdk/gems/auth/ext/auth/Cargo.toml"); const modelGemVersionPath = resolve(repoRoot, "sdk/gems/model/lib/model/version.rb"); const modelGemCargoTomlPath = resolve(repoRoot, "sdk/gems/model/ext/model/Cargo.toml"); +const emailGemVersionPath = resolve(repoRoot, "sdk/gems/email/lib/email/version.rb"); +const emailGemCargoTomlPath = resolve(repoRoot, "sdk/gems/email/ext/email/Cargo.toml"); +const emailGemLockPath = resolve(repoRoot, "sdk/gems/email/Gemfile.lock"); const mcpServerJsonPath = resolve(repoRoot, "server.json"); +const xcrsMcpServerJsonPath = resolve(repoRoot, "server-xcrs.json"); function readText(path) { return readFileSync(path, "utf8"); @@ -53,6 +60,50 @@ const [major = "0", minor = "0"] = releaseVersion.split("."); const rubySdkRequirement = `${major}.${minor}`; const updatedPaths = []; +if ( + updateFile(npmCliPackageJsonPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + + for (const packageName of Object.keys(parsed.optionalDependencies ?? {})) { + parsed.optionalDependencies[packageName] = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(npmCliPackageJsonPath); +} + +if ( + updateFile(npmCliPackageLockPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + parsed.packages[""].version = releaseVersion; + + for (const packageName of Object.keys(parsed.packages[""].optionalDependencies ?? {})) { + parsed.packages[""].optionalDependencies[packageName] = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(npmCliPackageLockPath); +} + +if ( + updateFile(nugetCliProjectPath, (content) => + replaceOrThrow( + content, + /[^<]+<\/PackageVersion>/, + `${releaseVersion}`, + `${nugetCliProjectPath} package version`, + ), + ) +) { + updatedPaths.push(nugetCliProjectPath); +} + if ( updateFile(sdkNpmPackageJsonPath, (content) => { const parsed = JSON.parse(content); @@ -130,6 +181,45 @@ if ( updatedPaths.push(modelGemCargoTomlPath); } +if ( + updateFile(emailGemVersionPath, (content) => + replaceOrThrow( + content, + /VERSION\s*=\s*'[^']+'/, + `VERSION = '${releaseVersion}'`, + `${emailGemVersionPath} VERSION constant`, + ), + ) +) { + updatedPaths.push(emailGemVersionPath); +} + +if ( + updateFile(emailGemCargoTomlPath, (content) => + replaceOrThrow( + content, + /^version\s*=\s*"[^"]+"/m, + `version = "${releaseVersion}"`, + `${emailGemCargoTomlPath} package version`, + ), + ) +) { + updatedPaths.push(emailGemCargoTomlPath); +} + +if ( + updateFile(emailGemLockPath, (content) => + replaceOrThrow( + content, + /(smbcloud-email \()[^)]+(\))/, + `$1${releaseVersion}$2`, + `${emailGemLockPath} gem version`, + ), + ) +) { + updatedPaths.push(emailGemLockPath); +} + if ( updateFile(mcpServerJsonPath, (content) => { const parsed = JSON.parse(content); @@ -145,6 +235,21 @@ if ( updatedPaths.push(mcpServerJsonPath); } +if ( + updateFile(xcrsMcpServerJsonPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + + for (const pkg of parsed.packages ?? []) { + pkg.version = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(xcrsMcpServerJsonPath); +} + console.log(`Release version: ${releaseVersion}`); if (updatedPaths.length === 0) { diff --git a/sdk/gems/email/Cargo.lock b/sdk/gems/email/Cargo.lock index 36f447b7..6d33da06 100644 --- a/sdk/gems/email/Cargo.lock +++ b/sdk/gems/email/Cargo.lock @@ -114,7 +114,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -123,7 +123,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -134,9 +134,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bumpalo" @@ -146,15 +146,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -177,9 +177,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] name = "chrono" @@ -197,9 +208,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -223,9 +234,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -233,9 +244,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -245,14 +256,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -292,26 +303,35 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "email" -version = "0.4.7" +version = "0.4.13" dependencies = [ "magnus", "serde_json", @@ -336,30 +356,30 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", @@ -395,23 +415,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasip2", + "rand_core", "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "heck" @@ -445,9 +465,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -455,9 +475,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -465,9 +485,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -484,9 +504,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -715,9 +735,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -782,7 +802,7 @@ checksum = "5968c820e2960565f647819f5928a42d6e874551cab9d88d75e3e0660d7f71e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -802,9 +822,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -814,9 +834,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -890,15 +910,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -925,9 +936,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -954,14 +965,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -975,60 +987,57 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1052,7 +1061,7 @@ dependencies = [ "quote", "regex", "shell-words", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1063,9 +1072,9 @@ checksum = "a35802679f07360454b418a5d1735c89716bde01d35b1560fc953c1415a0b3bb" [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1075,9 +1084,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1145,15 +1154,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "ring", @@ -1177,9 +1186,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1198,9 +1207,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1238,7 +1247,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1263,9 +1272,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1273,29 +1282,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1306,13 +1315,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -1368,7 +1377,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smbcloud-email-sdk" -version = "0.4.7" +version = "0.4.13" dependencies = [ "log", "reqwest", @@ -1382,7 +1391,7 @@ dependencies = [ [[package]] name = "smbcloud-model" -version = "0.4.7" +version = "0.4.13" dependencies = [ "chrono", "log", @@ -1398,9 +1407,9 @@ dependencies = [ [[package]] name = "smbcloud-network" -version = "0.4.7" +version = "0.4.13" dependencies = [ - "clap 4.6.1", + "clap 4.6.4", "log", "reqwest", "serde", @@ -1411,9 +1420,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1518,7 +1527,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1540,9 +1549,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1566,7 +1586,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1580,29 +1600,29 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -1619,9 +1639,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1634,9 +1654,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1677,7 +1697,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -1720,7 +1740,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1779,7 +1799,7 @@ dependencies = [ "quote", "state", "structopt", - "syn 2.0.118", + "syn 2.0.119", "tsync-macro", "walkdir", ] @@ -1881,15 +1901,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1932,7 +1943,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1967,9 +1978,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2035,7 +2046,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2046,7 +2057,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2082,15 +2093,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2124,30 +2126,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2160,12 +2145,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2178,12 +2157,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2196,24 +2169,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2226,12 +2187,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2244,12 +2199,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2262,12 +2211,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2280,18 +2223,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" @@ -2317,30 +2248,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2358,7 +2269,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -2398,11 +2309,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/gems/email/Gemfile.lock b/sdk/gems/email/Gemfile.lock index 485b57f4..39ad5b7a 100644 --- a/sdk/gems/email/Gemfile.lock +++ b/sdk/gems/email/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smbcloud-email (0.4.7) + smbcloud-email (0.4.13) json rb_sys (~> 0.9.91) diff --git a/sdk/gems/email/ext/email/Cargo.toml b/sdk/gems/email/ext/email/Cargo.toml index 9342b19c..0c397c41 100644 --- a/sdk/gems/email/ext/email/Cargo.toml +++ b/sdk/gems/email/ext/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "0.4.7" +version = "0.4.13" edition = "2024" authors = ["Seto Elkahfi "] publish = false diff --git a/sdk/gems/email/lib/email/version.rb b/sdk/gems/email/lib/email/version.rb index 4d8cbc3f..62b06b02 100644 --- a/sdk/gems/email/lib/email/version.rb +++ b/sdk/gems/email/lib/email/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Email - VERSION = '0.4.7' + VERSION = '0.4.13' end diff --git a/server-xcrs.json b/server-xcrs.json new file mode 100644 index 00000000..63d6f86b --- /dev/null +++ b/server-xcrs.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.smbcloudXYZ/xcrs", + "title": "XCRS Xcode Simulator Tools", + "description": "Rust-native Xcode command-line and Apple simulator automation tools exposed through MCP.", + "version": "0.4.13", + "websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs", + "repository": { + "url": "https://github.com/smbcloudXYZ/smbcloud-cli", + "source": "github", + "id": "597171611", + "subfolder": "crates/xcrs" + }, + "packages": [ + { + "registryType": "cargo", + "registryBaseUrl": "https://crates.io", + "identifier": "xcrs", + "version": "0.4.13", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "named", + "name": "--mcp", + "description": "Run the XCRS server as an MCP server over stdio.", + "isRequired": true + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "categories": [ + "developer-tools", + "xcode", + "ios", + "simulator" + ], + "keywords": [ + "Xcode", + "iOS simulator", + "tvOS", + "visionOS", + "watchOS", + "macOS", + "ControlKit" + ] + } + } +} From 11f671aed90bc552db0fadf64c877eb56dc88d8e Mon Sep 17 00:00:00 2001 From: keypair34 <216233964+keypair34@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:10:05 +0200 Subject: [PATCH 09/10] Support physical devices in ControlKit MCP tools smb_simulator_press_button, launch_app, terminate_app, and related tools required simulator_name/simulator_udid resolved via simctl, so they couldn't reach a ControlKit runner on a real iOS/tvOS device over the network. Add an optional host field (matching the pattern already used by smb_macos_click/smb_visionos_spatial_tap/smb_watchos_tap) and route app launch/terminate through the RPC methods instead of simctl when a host is given. --- crates/xcrs/src/mcp.rs | 98 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index a4c17231..43f73db6 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -54,6 +54,9 @@ pub struct SimulatorAppArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -84,6 +87,9 @@ pub struct SimulatorTapArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -101,6 +107,9 @@ pub struct SimulatorTextArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -116,6 +125,9 @@ pub struct SimulatorSwipeArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -137,6 +149,9 @@ pub struct SimulatorOrientationArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -152,6 +167,9 @@ pub struct SimulatorControlKitArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -165,6 +183,9 @@ pub struct SimulatorButtonArgs { /// Simulator UDID. #[serde(default)] pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, /// Local ControlKit JSON-RPC port. Defaults to 12004. #[serde(default)] pub controlkit_port: Option, @@ -271,11 +292,18 @@ macro_rules! xcrs_mcp_tools { fn controlkit_from_target( simulator_name: &Option, simulator_udid: &Option, + host: &Option, controlkit_port: Option, ) -> ::std::result::Result< - ($crate::Simulator, $crate::ControlKit), + (Option<$crate::Simulator>, $crate::ControlKit), ::rmcp::model::ErrorData, > { + if let Some(host) = host { + return Ok(( + None, + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)), + )); + } let target = $crate::mcp::SimulatorTargetArgs { simulator_name: simulator_name.clone(), simulator_udid: simulator_udid.clone(), @@ -283,11 +311,18 @@ macro_rules! xcrs_mcp_tools { }; let simulator = Self::simulator_from_target(&target)?; Ok(( - simulator, + Some(simulator), $crate::ControlKit::new(controlkit_port.unwrap_or(12004)), )) } + fn target_label(simulator: &Option<$crate::Simulator>, host: &Option) -> String { + if let Some(simulator) = simulator { + return simulator.name.clone(); + } + host.clone().unwrap_or_else(|| "127.0.0.1".to_string()) + } + fn controlkit_from_endpoint( endpoint: &$crate::mcp::ControlKitEndpointArgs, ) -> ::std::result::Result< @@ -600,6 +635,25 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { + if let Some(host) = &args.host { + let controlkit = + $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.launch", + ::serde_json::json!({ "bundleId": args.bundle_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + return Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Launched {} on {}.", + args.bundle_id, host + )), + ])); + } let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, @@ -635,6 +689,25 @@ macro_rules! xcrs_mcp_tools { ::rmcp::model::CallToolResult, ::rmcp::model::ErrorData, > { + if let Some(host) = &args.host { + let controlkit = + $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.terminate", + ::serde_json::json!({ "bundleId": args.bundle_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + return Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Terminated {} on {}.", + args.bundle_id, host + )), + ])); + } let target = $crate::mcp::SimulatorTargetArgs { simulator_name: args.simulator_name, simulator_udid: args.simulator_udid, @@ -708,6 +781,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; let result = controlkit @@ -742,6 +816,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; let ui = controlkit @@ -777,6 +852,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; controlkit @@ -791,7 +867,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Tapped ({}, {}) on {}.", - args.x, args.y, simulator.name + args.x, args.y, Self::target_label(&simulator, &args.host) )), ])) } @@ -814,6 +890,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; controlkit @@ -825,7 +902,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Typed text on {}.", - simulator.name + Self::target_label(&simulator, &args.host) )), ])) } @@ -848,6 +925,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; controlkit @@ -867,7 +945,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Swiped on {}.", - simulator.name + Self::target_label(&simulator, &args.host) )), ])) } @@ -890,6 +968,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; controlkit @@ -901,7 +980,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Pressed Home on {}.", - simulator.name + Self::target_label(&simulator, &args.host) )), ])) } @@ -924,6 +1003,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; let supported_buttons = [ @@ -954,7 +1034,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Pressed {} on {}.", - args.button, simulator.name + args.button, Self::target_label(&simulator, &args.host) )), ])) } @@ -977,6 +1057,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; let result = controlkit @@ -1011,6 +1092,7 @@ macro_rules! xcrs_mcp_tools { let (simulator, controlkit) = Self::controlkit_from_target( &args.simulator_name, &args.simulator_udid, + &args.host, args.controlkit_port, )?; let orientation = args.orientation.to_uppercase(); @@ -1032,7 +1114,7 @@ macro_rules! xcrs_mcp_tools { Ok(::rmcp::model::CallToolResult::success(vec![ ::rmcp::model::ContentBlock::text(format!( "Set orientation to {} on {}.", - orientation, simulator.name + orientation, Self::target_label(&simulator, &args.host) )), ])) } From 8be27ebd7e2b7164b1fb33097af9b6e7658307ce Mon Sep 17 00:00:00 2001 From: keypair34 <216233964+keypair34@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:22:17 +0200 Subject: [PATCH 10/10] Add smb_device_screenshot and document physical-device ControlKit support Simctl has no equivalent for a real device, so smb_simulator_screenshot couldn't capture one. Add a devicectl-backed Devicectl::screenshot next to Simctl::screenshot and expose it as smb_device_screenshot/ xcrs_device_screenshot. Also document the host field's physical-device usage across the ControlKit tool surface, which the previous commit added but left undocumented. --- crates/cli/src/mcp_xcrs.rs | 3 +- crates/xcrs/src/mcp.rs | 40 +++++++++++++++++++++++-- crates/xcrs/src/xcrs.rs | 35 ++++++++++++++++++++++ docs/controlkit.md | 60 ++++++++++++++++++++++++++++++++++---- docs/mcp.md | 30 +++++++++++++------ 5 files changed, 150 insertions(+), 18 deletions(-) diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs index 5c736481..2df20e93 100644 --- a/crates/cli/src/mcp_xcrs.rs +++ b/crates/cli/src/mcp_xcrs.rs @@ -21,5 +21,6 @@ xcrs::xcrs_mcp_tools!( "smb_controlkit_capabilities", "smb_macos_click", "smb_visionos_spatial_tap", - "smb_watchos_tap" + "smb_watchos_tap", + "smb_device_screenshot" ); diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs index 43f73db6..0869087f 100644 --- a/crates/xcrs/src/mcp.rs +++ b/crates/xcrs/src/mcp.rs @@ -16,6 +16,13 @@ pub struct SimulatorFindArgs { pub name: String, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeviceScreenshotArgs { + /// Physical device identifier known to CoreDevice: UDID, ECID, serial + /// number, user-provided name, or DNS name. + pub device: String, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct IosAppTestArgs { /// Exact simulator name to use. @@ -261,7 +268,8 @@ macro_rules! xcrs_mcp_tools { $controlkit_capabilities_name:literal, $macos_click_name:literal, $visionos_spatial_tap_name:literal, - $watchos_tap_name:literal + $watchos_tap_name:literal, + $device_screenshot_name:literal ) => { #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] impl $server { @@ -620,6 +628,33 @@ macro_rules! xcrs_mcp_tools { ])) } + #[::rmcp::tool( + name = $device_screenshot_name, + description = "Capture a PNG screenshot from a physical iOS, tvOS, watchOS, or visionOS device paired with this Mac. Accepts a UDID, ECID, serial number, user-provided name, or DNS name." + )] + async fn device_screenshot( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::DeviceScreenshotArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let screenshot = $crate::XcodeCommandLineTools::new() + .devicectl() + .screenshot(&args.device) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let encoded = $crate::encode_base64(screenshot); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::image(encoded, "image/png"), + ])) + } + #[::rmcp::tool( name = $simulator_launch_app_name, description = "Launch an installed app on an Apple simulator by bundle identifier." @@ -1143,7 +1178,8 @@ xcrs_mcp_tools!( "xcrs_controlkit_capabilities", "xcrs_macos_click", "xcrs_visionos_spatial_tap", - "xcrs_watchos_tap" + "xcrs_watchos_tap", + "xcrs_device_screenshot" ); #[rmcp::tool_handler(router = Self::xcrs_tool_router())] diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs index c7e957d3..a19f0b79 100644 --- a/crates/xcrs/src/xcrs.rs +++ b/crates/xcrs/src/xcrs.rs @@ -194,6 +194,10 @@ impl XcodeCommandLineTools { Xcodebuild { tools: self } } + pub fn devicectl(&self) -> Devicectl<'_> { + Devicectl { tools: self } + } + fn run_xcrun(&self, args: I) -> Result where I: IntoIterator, @@ -384,6 +388,37 @@ impl Simctl<'_> { } } +pub struct Devicectl<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Devicectl<'_> { + /// Captures a PNG screenshot from a physical device known to CoreDevice. + /// `device` accepts anything `devicectl --device` does: UDID, ECID, + /// serial number, user-provided name, or DNS name. + pub fn screenshot(&self, device: &str) -> Result> { + let temporary_directory = tempfile::tempdir()?; + let screenshot_path = temporary_directory.path().join("screenshot.png"); + let screenshot_path_string = screenshot_path + .to_str() + .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?; + + self.tools.run_xcrun([ + "devicectl", + "device", + "capture", + "screenshot", + "--device", + device, + "--destination", + screenshot_path_string, + ])?; + + std::fs::read(&screenshot_path) + .with_context(|| format!("failed to read {}", screenshot_path.display())) + } +} + #[derive(Debug, Clone)] pub enum XcodeProject { Project(PathBuf), diff --git a/docs/controlkit.md b/docs/controlkit.md index 73539373..08d1fd77 100644 --- a/docs/controlkit.md +++ b/docs/controlkit.md @@ -57,6 +57,38 @@ The shared XCTest runner starts a local ControlKit JSON-RPC server on The macOS UI-test target includes the network-server entitlement required to bind the local port. +## Run on a physical device + +The same runner also works installed on a real iOS, tvOS, watchOS, or +visionOS device, not just a simulator. Build and start it against the +device's UDID (find it with `xcrun devicectl list devices`) instead of a +simulator destination: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + build-for-testing + +TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0 xcodebuild \ + -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + test-without-building +``` + +The `CONTROLKIT_LISTEN_HOST` environment variable controls what the +in-process RPC server binds to (`Shared/ControlKitRPCServer.swift`) — it +defaults to `127.0.0.1`, which is loopback-only *on the device itself* and +unreachable from your Mac. `xcodebuild` forwards any `TEST_RUNNER_`-prefixed +environment variable into the test process with the prefix stripped, so +setting `TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0` makes the server bind on +every interface, including the one reachable over your LAN. Find the device's +address with its Bonjour hostname (`.local`, discoverable via +`dns-sd -B _airplay._tcp local` for an Apple TV) or its CoreDevice tunnel +address (`xcrun devicectl device info details --device `, under +`Tunnel IP Address`). + ## Connect through `smb --mcp` Start the CLI MCP server: @@ -65,8 +97,10 @@ Start the CLI MCP server: smb --mcp ``` -The runner tools accept either `simulator_name` or `simulator_udid`. The -standalone `xcrs --mcp` server exposes the same tools with the `xcrs_` prefix. +The runner tools accept either `simulator_name` or `simulator_udid` for a +simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a +physical device or any other remote runner. The standalone `xcrs --mcp` +server exposes the same tools with the `xcrs_` prefix. | Tool | Runner | Purpose | | --- | --- | --- | @@ -74,10 +108,13 @@ standalone `xcrs --mcp` server exposes the same tools with the `xcrs_` prefix. | `smb_macos_click` | macOS | Click at screen coordinates. | | `smb_visionos_spatial_tap` | visionOS | Perform a spatial tap. | | `smb_watchos_tap` | watchOS | Perform a touch tap. | -| `smb_simulator_tap` | iOS / tvOS | Perform a touch tap. | -| `smb_simulator_type_text` | iOS / tvOS | Type into the focused field. | -| `smb_simulator_swipe` | iOS / tvOS | Swipe between coordinates. | -| `smb_simulator_screenshot` | All simulator runners | Capture a PNG screenshot. | +| `smb_simulator_tap` | iOS / tvOS, simulator or physical | Perform a touch tap. | +| `smb_simulator_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. | +| `smb_simulator_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | +| `smb_simulator_press_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | +| `smb_simulator_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | +| `smb_simulator_screenshot` | Simulator only | Capture a PNG screenshot via `simctl`. | +| `smb_device_screenshot` | Physical device only | Capture a PNG screenshot via `devicectl device capture screenshot` — works independently of the ControlKit runner, so it succeeds even if the RPC server isn't running. | | `smb_simulator_ui_dump` | All UI-test runners | Read the accessibility hierarchy. | For a local macOS runner, omit simulator fields and pass `host` and @@ -92,6 +129,17 @@ For a local macOS runner, omit simulator fields and pass `host` and } ``` +For a physical device, pass its Bonjour hostname or tunnel address as `host` +instead: + +```json +{ + "host": ".local", + "controlkit_port": 12004, + "button": "select" +} +``` + Platform-specific operations intentionally reject unsupported input. For example, visionOS rejects raw touch, watchOS rejects pointer and spatial input, and macOS rejects touch and Home-button requests. diff --git a/docs/mcp.md b/docs/mcp.md index 25a366d7..42a2c9f2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -223,6 +223,17 @@ ControlKit arguments default to `127.0.0.1:12004`. For a local macOS runner, omit the simulator fields and use the optional `host` and `controlkit_port` fields. +The same `host` field also targets a **physical** iOS/tvOS/watchOS/visionOS +device running a ControlKit runner reachable over the network (see +[ControlKit runners](./controlkit.md)) — omit `simulator_name`/`simulator_udid` +and pass the device's host and the runner's listen port instead. This applies +to `smb_simulator_tap`, `smb_simulator_type_text`, `smb_simulator_swipe`, +`smb_simulator_press_button`, `smb_simulator_press_home`, +`smb_simulator_orientation_get`/`set`, `smb_simulator_ui_dump`, +`smb_simulator_list_elements`, and `smb_simulator_launch_app`/`terminate_app` +(the latter two route through the runner's `device.apps.launch`/`terminate` +RPC methods instead of `simctl` when `host` is given). + | Tool | Arguments | Returns | | --- | --- | --- | | `smb_simulator_list` | _(none)_ | All Apple simulator devices known to Xcode. | @@ -231,17 +242,18 @@ fields. | `smb_macos_click` | `x`, `y`, plus optional endpoint fields | A pointer click on a macOS runner. | | `smb_visionos_spatial_tap` | `x`, `y`, plus optional endpoint fields | A spatial tap on a visionOS runner. | | `smb_watchos_tap` | `x`, `y`, plus optional endpoint fields | A touch tap on a watchOS runner. | -| `smb_simulator_tap` | simulator target, `x`, `y` | A touch tap through ControlKit. | -| `smb_simulator_type_text` | simulator target, `text` | Text input through ControlKit. | -| `smb_simulator_swipe` | simulator target, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. | -| `smb_simulator_press_button` | simulator target, `button` | A supported remote-button press. | -| `smb_simulator_press_home` | simulator target | An iOS Home-button press. | -| `smb_simulator_orientation_get`/`set` | simulator target; `orientation` for `set` | Current or updated iOS orientation. | -| `smb_simulator_launch_app`/`terminate_app` | simulator target, `bundle_id` | App lifecycle confirmation. | +| `smb_simulator_tap` | simulator target or `host`, `x`, `y` | A touch tap through ControlKit. | +| `smb_simulator_type_text` | simulator target or `host`, `text` | Text input through ControlKit. | +| `smb_simulator_swipe` | simulator target or `host`, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. | +| `smb_simulator_press_button` | simulator target or `host`, `button` | A supported remote-button press. | +| `smb_simulator_press_home` | simulator target or `host` | An iOS Home-button press. | +| `smb_simulator_orientation_get`/`set` | simulator target or `host`; `orientation` for `set` | Current or updated iOS orientation. | +| `smb_simulator_launch_app`/`terminate_app` | simulator target or `host`, `bundle_id` | App lifecycle confirmation. | | `smb_simulator_open_url` | simulator target, `url` | URL-open confirmation. | -| `smb_simulator_ui_dump` | simulator target | Accessibility UI hierarchy. | -| `smb_simulator_list_elements` | simulator target | Actionable accessibility elements. | +| `smb_simulator_ui_dump` | simulator target or `host` | Accessibility UI hierarchy. | +| `smb_simulator_list_elements` | simulator target or `host` | Actionable accessibility elements. | | `smb_simulator_screenshot` | simulator target | A PNG image. | +| `smb_device_screenshot` | `device` (UDID, ECID, serial number, name, or DNS name) | A PNG image captured from a physical device via `devicectl`. | | `smb_ios_app_test` | simulator target, `app_path`, `bundle_id` | Installed/launched app details. | The standalone `xcrs --mcp` server exposes the same runner surface with the