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