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