1use crate::ascapi_mcp_tools;
14use anyhow::{anyhow, Result};
15use rmcp::{
16 model::{Implementation, ServerCapabilities, ServerInfo},
17 transport::stdio,
18 ServerHandler, ServiceExt,
19};
20
21#[derive(Debug, Clone, Default)]
22pub struct AscapiMcpServer;
23
24impl AscapiMcpServer {
25 pub fn new() -> Self {
26 Self
27 }
28}
29
30ascapi_mcp_tools!(
31 AscapiMcpServer,
32 "app_list",
33 "app_get",
34 "app_update",
35 "app_info_list",
36 "build_list",
37 "bundle_id_list",
38 "bundle_id_create",
39 "app_store_version_list",
40 "app_store_version_get",
41 "app_store_version_create",
42 "app_store_version_delete",
43 "app_store_version_set_build",
44 "app_info_localization_list",
45 "app_info_localization_create",
46 "app_info_localization_update",
47 "app_info_localization_delete",
48 "app_store_version_localization_list",
49 "app_store_version_localization_create",
50 "app_store_version_localization_update",
51 "app_store_version_localization_delete",
52 "app_screenshot_set_list",
53 "app_screenshot_set_create",
54 "app_screenshot_set_delete",
55 "app_screenshot_list",
56 "app_screenshot_upload",
57 "app_screenshot_delete",
58 "certificate_list",
59 "certificate_create"
60);
61
62#[rmcp::tool_handler(router = Self::ascapi_tool_router())]
63impl ServerHandler for AscapiMcpServer {
64 fn get_info(&self) -> ServerInfo {
65 let mut implementation = Implementation::from_build_env();
66 implementation.name = "ascapi".to_string();
67 implementation.version = env!("CARGO_PKG_VERSION").to_string();
68
69 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
70 .with_server_info(implementation)
71 .with_instructions(
72 "ascapi exposes App Store Connect signing certificates (certificate_list, \
73 certificate_create). Certificates are issued by generating an RSA 2048 key \
74 pair locally and sending only a signing request to Apple, so the private key \
75 never leaves this machine and Apple keeps no copy of it: a lost key file \
76 makes its certificate permanently unusable. Revocation is deliberately not \
77 offered here, because revoking a distribution certificate invalidates every \
78 provisioning profile embedding it for the whole team at once; run \
79 `asc certificates revoke` from a terminal instead. Requires ASC_API_KEY, \
80 ASC_ISSUER_ID, and a readable .p8 key.",
81 )
82 }
83}
84
85pub async fn serve() -> Result<()> {
90 let running = AscapiMcpServer::new()
91 .serve(stdio())
92 .await
93 .map_err(|error| anyhow!("Failed to start ascapi MCP server: {error}"))?;
94 running
95 .waiting()
96 .await
97 .map_err(|error| anyhow!("ascapi MCP server stopped unexpectedly: {error}"))?;
98 Ok(())
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104
105 const CANONICAL_TOOL_NAMES: [&str; 28] = [
109 "app_list",
110 "app_get",
111 "app_update",
112 "app_info_list",
113 "build_list",
114 "bundle_id_list",
115 "bundle_id_create",
116 "app_store_version_list",
117 "app_store_version_get",
118 "app_store_version_create",
119 "app_store_version_delete",
120 "app_store_version_set_build",
121 "app_info_localization_list",
122 "app_info_localization_create",
123 "app_info_localization_update",
124 "app_info_localization_delete",
125 "app_store_version_localization_list",
126 "app_store_version_localization_create",
127 "app_store_version_localization_update",
128 "app_store_version_localization_delete",
129 "app_screenshot_set_list",
130 "app_screenshot_set_create",
131 "app_screenshot_set_delete",
132 "app_screenshot_list",
133 "app_screenshot_upload",
134 "app_screenshot_delete",
135 "certificate_list",
136 "certificate_create",
137 ];
138
139 const FORBIDDEN_TOOL_NAMES: [&str; 4] = [
152 "certificate_revoke",
153 "certificate_delete",
154 "certificate_nuke",
155 "app_delete",
156 ];
157
158 #[test]
159 fn tool_router_exposes_exactly_the_canonical_tools() {
160 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
161
162 assert_eq!(
163 tools.len(),
164 CANONICAL_TOOL_NAMES.len(),
165 "expected exactly {} canonical tools, found {}",
166 CANONICAL_TOOL_NAMES.len(),
167 tools.len()
168 );
169
170 let mut actual: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
171 actual.sort_unstable();
172 let mut expected = CANONICAL_TOOL_NAMES;
173 expected.sort_unstable();
174 assert_eq!(actual, expected);
175 }
176
177 #[test]
178 fn no_irreversible_tool_is_ever_exposed() {
179 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
180 for tool in &tools {
181 assert!(
182 !FORBIDDEN_TOOL_NAMES.contains(&tool.name.as_ref()),
183 "{} is irreversible or team-wide and must not be reachable over MCP",
184 tool.name
185 );
186 }
187 }
188
189 #[test]
190 fn every_delete_tool_is_annotated_destructive() {
191 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
195 let deletes: Vec<_> = tools
196 .iter()
197 .filter(|tool| tool.name.ends_with("_delete"))
198 .collect();
199 assert!(
200 !deletes.is_empty(),
201 "expected some delete tools; if they were removed, drop this test with them"
202 );
203 for tool in deletes {
204 let annotations = tool
205 .annotations
206 .as_ref()
207 .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
208 assert_eq!(
209 annotations.destructive_hint,
210 Some(true),
211 "{} deletes something and must be annotated destructive",
212 tool.name
213 );
214 }
215 }
216
217 #[test]
218 fn read_only_tools_are_never_annotated_destructive() {
219 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
220 for tool in &tools {
221 let Some(annotations) = tool.annotations.as_ref() else {
222 continue;
223 };
224 if annotations.read_only_hint == Some(true) {
225 assert_ne!(
226 annotations.destructive_hint,
227 Some(true),
228 "{} claims to be read-only and destructive at once",
229 tool.name
230 );
231 }
232 }
233 }
234
235 #[test]
236 fn tool_names_carry_no_server_prefix() {
237 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
238 for tool in &tools {
239 assert!(
240 !tool.name.starts_with("ascapi_") && !tool.name.starts_with("smb_"),
241 "{} should not repeat a server prefix",
242 tool.name
243 );
244 }
245 }
246
247 #[test]
248 fn every_tool_has_a_meaningful_concise_title() {
249 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
250 for tool in &tools {
251 let title = tool
252 .title
253 .as_deref()
254 .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name));
255 assert!(!title.trim().is_empty(), "{} has a blank title", tool.name);
256 assert!(
257 title.len() <= 40,
258 "{} title should be concise (<= 40 chars), got {title:?}",
259 tool.name
260 );
261 assert_ne!(
262 title,
263 tool.name.as_ref(),
264 "{} title should be a human-readable label, not the raw tool name",
265 tool.name
266 );
267 }
268 }
269
270 #[test]
271 fn every_tool_has_a_front_loaded_transparent_description() {
272 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
273 for tool in &tools {
274 let description = tool
275 .description
276 .as_deref()
277 .unwrap_or_else(|| panic!("{} is missing a description", tool.name));
278
279 for required in [
280 "Purpose:",
281 "When to use",
282 "Behavior:",
283 "Prerequisites:",
284 "Failure modes:",
285 "Limitations:",
286 ] {
287 assert!(
288 description.contains(required),
289 "{} description is missing the {required:?} section",
290 tool.name
291 );
292 }
293 assert!(
294 description.starts_with("Purpose:"),
295 "{} description should front-load its purpose",
296 tool.name
297 );
298 assert!(
301 description.len() >= 150,
302 "{} description is too terse for behavioral transparency ({} chars)",
303 tool.name,
304 description.len()
305 );
306 }
307 }
308
309 #[test]
310 fn every_tool_has_internally_consistent_annotations() {
311 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
312 for tool in &tools {
313 let annotations = tool
314 .annotations
315 .as_ref()
316 .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
317 let annotations_title = annotations
318 .title
319 .as_deref()
320 .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name));
321 assert_eq!(
322 Some(annotations_title),
323 tool.title.as_deref(),
324 "{} top-level title and annotations title must agree",
325 tool.name
326 );
327 assert!(
328 !(annotations.read_only_hint == Some(true)
329 && annotations.destructive_hint == Some(true)),
330 "{} annotations contradict: read_only_hint and destructive_hint are both true",
331 tool.name
332 );
333 }
334 }
335
336 #[test]
337 fn every_input_schema_property_has_a_description() {
338 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
339 for tool in &tools {
340 let Some(properties) = tool
341 .input_schema
342 .get("properties")
343 .and_then(|value| value.as_object())
344 else {
345 continue;
346 };
347 for (property_name, property_schema) in properties {
348 let description = property_schema
349 .get("description")
350 .and_then(|value| value.as_str())
351 .unwrap_or_default();
352 assert!(
353 !description.trim().is_empty(),
354 "{}.{} is missing a parameter description",
355 tool.name,
356 property_name
357 );
358 }
359 }
360 }
361}