6 files changed
+233
-3
Cargo.lock
+2
@@ -3999,11 +3999,13 @@ name = "xcrs"
3999
version = "0.1.0"
4000
dependencies = [
4001
"anyhow",
4002
+ "base64",
4003
"clap 4.6.2",
4004
"rmcp",
4005
"schemars",
4006
"serde",
4007
"serde_json",
4008
+ "tempfile",
4009
"tokio",
4010
]
4011
Cargo.toml
+1
@@ -5,6 +5,7 @@ members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"]
5
[workspace.dependencies]
6
anyhow = "1.0.58"
7
async-trait = "0.1.51"
8
+base64 = "0.22"
9
chrono = "0.4"
10
clap = "4.1.6"
11
clap_mangen = "0.2.10"
crates/cli/src/mcp_xcrs.rs
+5
-1
@@ -4,5 +4,9 @@ xcrs::xcrs_mcp_tools!(
4
SmbMcpServer,
5
"smb_simulator_list",
6
"smb_simulator_find",
7
- "smb_ios_app_test"
7
+ "smb_ios_app_test",
8
+ "smb_simulator_screenshot",
9
+ "smb_simulator_launch_app",
10
+ "smb_simulator_terminate_app",
11
+ "smb_simulator_open_url"
12
);
crates/xcrs/Cargo.toml
+2
@@ -19,9 +19,11 @@ path = "src/main.rs"
19
20
[dependencies]
21
anyhow = { workspace = true }
22
+base64 = { workspace = true }
23
clap = { workspace = true, features = ["derive", "env"] }
24
rmcp = { workspace = true }
25
schemars = { workspace = true }
26
serde = { workspace = true, features = ["derive"] }
27
serde_json = { workspace = true }
28
+tempfile = { workspace = true }
29
tokio = { workspace = true, features = ["full"] }
crates/xcrs/src/mcp.rs
+203
-2
@@ -33,6 +33,40 @@ pub struct IosAppTestArgs {
33
pub terminate_before_launch: bool,
34
}
35
36
+#[derive(Debug, Deserialize, JsonSchema)]
37
+pub struct SimulatorTargetArgs {
38
+ /// Exact simulator name.
39
+ #[serde(default)]
40
+ pub simulator_name: Option<String>,
41
+ /// Simulator UDID.
42
+ #[serde(default)]
43
+ pub simulator_udid: Option<String>,
44
+}
45
+
46
+#[derive(Debug, Deserialize, JsonSchema)]
47
+pub struct SimulatorAppArgs {
48
+ /// Exact simulator name.
49
+ #[serde(default)]
50
+ pub simulator_name: Option<String>,
51
+ /// Simulator UDID.
52
+ #[serde(default)]
53
+ pub simulator_udid: Option<String>,
54
+ /// App bundle identifier.
55
+ pub bundle_id: String,
56
+}
57
+
58
+#[derive(Debug, Deserialize, JsonSchema)]
59
+pub struct SimulatorOpenUrlArgs {
60
+ /// Exact simulator name.
61
+ #[serde(default)]
62
+ pub simulator_name: Option<String>,
63
+ /// Simulator UDID.
64
+ #[serde(default)]
65
+ pub simulator_udid: Option<String>,
66
+ /// HTTP(S) URL or custom URL scheme.
67
+ pub url: String,
68
+}
69
+
70
#[derive(Debug, Default)]
71
pub struct XcrsMcpServer;
72
@@ -44,9 +78,42 @@ impl XcrsMcpServer {
78
79
#[macro_export]
80
macro_rules! xcrs_mcp_tools {
47
- ($server:ty, $simulator_list_name:literal, $simulator_find_name:literal, $ios_app_test_name:literal) => {
81
+ (
82
+ $server:ty,
83
+ $simulator_list_name:literal,
84
+ $simulator_find_name:literal,
85
+ $ios_app_test_name:literal,
86
+ $simulator_screenshot_name:literal,
87
+ $simulator_launch_app_name:literal,
88
+ $simulator_terminate_app_name:literal,
89
+ $simulator_open_url_name:literal
90
+ ) => {
91
#[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
92
impl $server {
93
+ fn simulator_from_target(
94
+ target: &$crate::mcp::SimulatorTargetArgs,
95
+ ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> {
96
+ let tools = $crate::XcodeCommandLineTools::new();
97
+ match (&target.simulator_udid, &target.simulator_name) {
98
+ (Some(udid), _) => tools
99
+ .simctl()
100
+ .find_simulator_by_udid(udid)
101
+ .map_err(|error| {
102
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
103
+ }),
104
+ (None, Some(name)) => tools
105
+ .simctl()
106
+ .find_simulator_by_name(name)
107
+ .map_err(|error| {
108
+ ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
109
+ }),
110
+ (None, None) => Err(::rmcp::model::ErrorData::invalid_request(
111
+ "Provide either simulator_name or simulator_udid.",
112
+ None,
113
+ )),
114
+ }
115
+ }
116
+
117
#[::rmcp::tool(
118
name = $simulator_list_name,
119
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 {
198
::rmcp::model::ContentBlock::json(&result)?,
199
]))
200
}
201
+
202
+ #[::rmcp::tool(
203
+ name = $simulator_screenshot_name,
204
+ description = "Capture a PNG screenshot from an iOS simulator."
205
+ )]
206
+ async fn simulator_screenshot(
207
+ &self,
208
+ ::rmcp::handler::server::wrapper::Parameters(
209
+ args,
210
+ ): ::rmcp::handler::server::wrapper::Parameters<
211
+ $crate::mcp::SimulatorTargetArgs,
212
+ >,
213
+ ) -> ::std::result::Result<
214
+ ::rmcp::model::CallToolResult,
215
+ ::rmcp::model::ErrorData,
216
+ > {
217
+ let simulator = Self::simulator_from_target(&args)?;
218
+ let screenshot = $crate::XcodeCommandLineTools::new()
219
+ .simctl()
220
+ .screenshot(&simulator.udid)
221
+ .map_err(|error| {
222
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
223
+ })?;
224
+ let encoded = $crate::encode_base64(screenshot);
225
+ Ok(::rmcp::model::CallToolResult::success(vec![
226
+ ::rmcp::model::ContentBlock::image(encoded, "image/png"),
227
+ ]))
228
+ }
229
+
230
+ #[::rmcp::tool(
231
+ name = $simulator_launch_app_name,
232
+ description = "Launch an installed app on an iOS simulator by bundle identifier."
233
+ )]
234
+ async fn simulator_launch_app(
235
+ &self,
236
+ ::rmcp::handler::server::wrapper::Parameters(
237
+ args,
238
+ ): ::rmcp::handler::server::wrapper::Parameters<
239
+ $crate::mcp::SimulatorAppArgs,
240
+ >,
241
+ ) -> ::std::result::Result<
242
+ ::rmcp::model::CallToolResult,
243
+ ::rmcp::model::ErrorData,
244
+ > {
245
+ let target = $crate::mcp::SimulatorTargetArgs {
246
+ simulator_name: args.simulator_name,
247
+ simulator_udid: args.simulator_udid,
248
+ };
249
+ let simulator = Self::simulator_from_target(&target)?;
250
+ $crate::XcodeCommandLineTools::new()
251
+ .simctl()
252
+ .launch_app(&simulator.udid, &args.bundle_id)
253
+ .map_err(|error| {
254
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
255
+ })?;
256
+ Ok(::rmcp::model::CallToolResult::success(vec![
257
+ ::rmcp::model::ContentBlock::text(format!(
258
+ "Launched {} on {}.",
259
+ args.bundle_id, simulator.name
260
+ )),
261
+ ]))
262
+ }
263
+
264
+ #[::rmcp::tool(
265
+ name = $simulator_terminate_app_name,
266
+ description = "Terminate an installed app on an iOS simulator by bundle identifier."
267
+ )]
268
+ async fn simulator_terminate_app(
269
+ &self,
270
+ ::rmcp::handler::server::wrapper::Parameters(
271
+ args,
272
+ ): ::rmcp::handler::server::wrapper::Parameters<
273
+ $crate::mcp::SimulatorAppArgs,
274
+ >,
275
+ ) -> ::std::result::Result<
276
+ ::rmcp::model::CallToolResult,
277
+ ::rmcp::model::ErrorData,
278
+ > {
279
+ let target = $crate::mcp::SimulatorTargetArgs {
280
+ simulator_name: args.simulator_name,
281
+ simulator_udid: args.simulator_udid,
282
+ };
283
+ let simulator = Self::simulator_from_target(&target)?;
284
+ $crate::XcodeCommandLineTools::new()
285
+ .simctl()
286
+ .terminate_app(&simulator.udid, &args.bundle_id)
287
+ .map_err(|error| {
288
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
289
+ })?;
290
+ Ok(::rmcp::model::CallToolResult::success(vec![
291
+ ::rmcp::model::ContentBlock::text(format!(
292
+ "Terminated {} on {}.",
293
+ args.bundle_id, simulator.name
294
+ )),
295
+ ]))
296
+ }
297
+
298
+ #[::rmcp::tool(
299
+ name = $simulator_open_url_name,
300
+ description = "Open a URL or custom URL scheme on an iOS simulator."
301
+ )]
302
+ async fn simulator_open_url(
303
+ &self,
304
+ ::rmcp::handler::server::wrapper::Parameters(
305
+ args,
306
+ ): ::rmcp::handler::server::wrapper::Parameters<
307
+ $crate::mcp::SimulatorOpenUrlArgs,
308
+ >,
309
+ ) -> ::std::result::Result<
310
+ ::rmcp::model::CallToolResult,
311
+ ::rmcp::model::ErrorData,
312
+ > {
313
+ let target = $crate::mcp::SimulatorTargetArgs {
314
+ simulator_name: args.simulator_name,
315
+ simulator_udid: args.simulator_udid,
316
+ };
317
+ let simulator = Self::simulator_from_target(&target)?;
318
+ $crate::XcodeCommandLineTools::new()
319
+ .simctl()
320
+ .open_url(&simulator.udid, &args.url)
321
+ .map_err(|error| {
322
+ ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
323
+ })?;
324
+ Ok(::rmcp::model::CallToolResult::success(vec![
325
+ ::rmcp::model::ContentBlock::text(format!(
326
+ "Opened {} on {}.",
327
+ args.url, simulator.name
328
+ )),
329
+ ]))
330
+ }
331
}
332
};
333
}
@@ -139,7 +336,11 @@ xcrs_mcp_tools!(
336
XcrsMcpServer,
337
"xcrs_simulator_list",
338
"xcrs_simulator_find",
142
- "xcrs_ios_app_test"
339
+ "xcrs_ios_app_test",
340
+ "xcrs_simulator_screenshot",
341
+ "xcrs_simulator_launch_app",
342
+ "xcrs_simulator_terminate_app",
343
+ "xcrs_simulator_open_url"
344
);
345
346
#[rmcp::tool_handler(router = Self::xcrs_tool_router())]
crates/xcrs/src/xcrs.rs
+20
@@ -9,6 +9,12 @@ pub mod mcp;
9
10
const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id=";
11
12
+pub fn encode_base64(data: impl AsRef<[u8]>) -> String {
13
+ use base64::{engine::general_purpose::STANDARD, Engine};
14
+
15
+ STANDARD.encode(data)
16
+}
17
+
18
#[derive(Debug, Clone)]
19
pub struct XcodeCommandLineTools {
20
xcrun_path: PathBuf,
@@ -169,6 +175,20 @@ impl Simctl<'_> {
175
Ok(())
176
}
177
178
+ pub fn screenshot(&self, udid: &str) -> Result<Vec<u8>> {
179
+ let temporary_directory = tempfile::tempdir()?;
180
+ let screenshot_path = temporary_directory.path().join("screenshot.png");
181
+ let screenshot_path_string = screenshot_path
182
+ .to_str()
183
+ .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
184
+
185
+ self.tools
186
+ .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?;
187
+
188
+ std::fs::read(&screenshot_path)
189
+ .with_context(|| format!("failed to read {}", screenshot_path.display()))
190
+ }
191
+
192
pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result<PathBuf> {
193
let output =
194
self.tools