development
rs 373 lines 12.9 KB
Raw
1 //! The `asc --mcp` server.
2 //!
3 //! Holds no state: every tool resolves its App Store Connect credentials
4 //! from the environment per call, so an unconfigured server still starts
5 //! and still answers `tools/list`, which is how a client discovers what
6 //! configuration it needs.
7 //!
8 //! The tool names are supplied here rather than baked into the contract
9 //! module, so a host that re-exposes these tools can namespace them
10 //! without forking the implementations. Same arrangement as `xcrs` in
11 //! `smbcloud-cli`.
12
13 use crate::ascapi_mcp_tools;
14 use anyhow::{anyhow, Result};
15 use rmcp::{
16 model::{Implementation, ServerCapabilities, ServerInfo},
17 transport::stdio,
18 ServerHandler, ServiceExt,
19 };
20
21 #[derive(Debug, Clone, Default)]
22 pub struct AscapiMcpServer;
23
24 impl AscapiMcpServer {
25 pub fn new() -> Self {
26 Self
27 }
28 }
29
30 ascapi_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 "play_listing_list",
64 "play_images_list",
65 "play_track_get"
66 );
67
68 #[rmcp::tool_handler(router = Self::ascapi_tool_router())]
69 impl ServerHandler for AscapiMcpServer {
70 fn get_info(&self) -> ServerInfo {
71 let mut implementation = Implementation::from_build_env();
72 implementation.name = "ascapi".to_string();
73 implementation.version = env!("CARGO_PKG_VERSION").to_string();
74
75 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
76 .with_server_info(implementation)
77 .with_instructions(
78 "ascapi exposes App Store Connect signing certificates (certificate_list, \
79 certificate_create). Certificates are issued by generating an RSA 2048 key \
80 pair locally and sending only a signing request to Apple, so the private key \
81 never leaves this machine and Apple keeps no copy of it: a lost key file \
82 makes its certificate permanently unusable. Revocation is deliberately not \
83 offered here, because revoking a distribution certificate invalidates every \
84 provisioning profile embedding it for the whole team at once; run \
85 `asc certificates revoke` from a terminal instead. Requires ASC_API_KEY, \
86 ASC_ISSUER_ID, and a readable .p8 key.",
87 )
88 }
89 }
90
91 /// Serve over stdio until the client disconnects.
92 ///
93 /// stdout carries the JSON-RPC stream from here on, so nothing in this
94 /// path may print to it. Diagnostics belong on stderr.
95 pub async fn serve() -> Result<()> {
96 let running = AscapiMcpServer::new()
97 .serve(stdio())
98 .await
99 .map_err(|error| anyhow!("Failed to start ascapi MCP server: {error}"))?;
100 running
101 .waiting()
102 .await
103 .map_err(|error| anyhow!("ascapi MCP server stopped unexpectedly: {error}"))?;
104 Ok(())
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110
111 /// The canonical tool names. Adding or renaming one is a
112 /// public-contract change, and this list is what makes that
113 /// deliberate rather than accidental.
114 const CANONICAL_TOOL_NAMES: [&str; 34] = [
115 "app_list",
116 "app_get",
117 "app_update",
118 "app_info_list",
119 "build_list",
120 "bundle_id_list",
121 "bundle_id_create",
122 "app_store_version_list",
123 "app_store_version_get",
124 "app_store_version_create",
125 "app_store_version_delete",
126 "app_store_version_set_build",
127 "app_store_version_build_get",
128 "app_info_localization_list",
129 "app_info_localization_create",
130 "app_info_localization_update",
131 "app_info_localization_delete",
132 "app_store_version_localization_list",
133 "app_store_version_localization_create",
134 "app_store_version_localization_update",
135 "app_store_version_localization_delete",
136 "app_screenshot_set_list",
137 "app_screenshot_set_create",
138 "app_screenshot_set_delete",
139 "app_screenshot_list",
140 "app_screenshot_upload",
141 "app_screenshot_delete",
142 "certificate_list",
143 "certificate_create",
144 "beta_group_list",
145 "beta_app_review_detail_get",
146 "play_listing_list",
147 "play_images_list",
148 "play_track_get",
149 ];
150
151 /// Never a tool, at any point, for any caller.
152 ///
153 /// Revoking a signing certificate invalidates every provisioning
154 /// profile embedding it, for every teammate and every CI job, at once
155 /// and irreversibly. No confirmation string a model types on a human's
156 /// behalf makes that safe. It lives on the command line only, where a
157 /// person is the one at the keyboard.
158 ///
159 /// The scoped deletes below are a different matter and *are* exposed:
160 /// a version, a localization, a screenshot set, or an image can all be
161 /// recreated by re-running the tool that made them. They carry
162 /// `destructive_hint` so a client can gate them on its own terms.
163 const FORBIDDEN_TOOL_NAMES: [&str; 4] = [
164 "certificate_revoke",
165 "certificate_delete",
166 "certificate_nuke",
167 "app_delete",
168 ];
169
170 #[test]
171 fn tool_router_exposes_exactly_the_canonical_tools() {
172 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
173
174 assert_eq!(
175 tools.len(),
176 CANONICAL_TOOL_NAMES.len(),
177 "expected exactly {} canonical tools, found {}",
178 CANONICAL_TOOL_NAMES.len(),
179 tools.len()
180 );
181
182 let mut actual: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
183 actual.sort_unstable();
184 let mut expected = CANONICAL_TOOL_NAMES;
185 expected.sort_unstable();
186 assert_eq!(actual, expected);
187 }
188
189 #[test]
190 fn no_irreversible_tool_is_ever_exposed() {
191 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
192 for tool in &tools {
193 assert!(
194 !FORBIDDEN_TOOL_NAMES.contains(&tool.name.as_ref()),
195 "{} is irreversible or team-wide and must not be reachable over MCP",
196 tool.name
197 );
198 }
199 }
200
201 #[test]
202 fn every_delete_tool_is_annotated_destructive() {
203 // The naming convention is load-bearing: a client deciding whether
204 // to prompt reads the annotation, not the name, so the two must
205 // agree or a delete slips through a confirmation gate.
206 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
207 let deletes: Vec<_> = tools
208 .iter()
209 .filter(|tool| tool.name.ends_with("_delete"))
210 .collect();
211 assert!(
212 !deletes.is_empty(),
213 "expected some delete tools; if they were removed, drop this test with them"
214 );
215 for tool in deletes {
216 let annotations = tool
217 .annotations
218 .as_ref()
219 .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
220 assert_eq!(
221 annotations.destructive_hint,
222 Some(true),
223 "{} deletes something and must be annotated destructive",
224 tool.name
225 );
226 }
227 }
228
229 #[test]
230 fn read_only_tools_are_never_annotated_destructive() {
231 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
232 for tool in &tools {
233 let Some(annotations) = tool.annotations.as_ref() else {
234 continue;
235 };
236 if annotations.read_only_hint == Some(true) {
237 assert_ne!(
238 annotations.destructive_hint,
239 Some(true),
240 "{} claims to be read-only and destructive at once",
241 tool.name
242 );
243 }
244 }
245 }
246
247 #[test]
248 fn tool_names_carry_no_server_prefix() {
249 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
250 for tool in &tools {
251 assert!(
252 !tool.name.starts_with("ascapi_") && !tool.name.starts_with("smb_"),
253 "{} should not repeat a server prefix",
254 tool.name
255 );
256 }
257 }
258
259 #[test]
260 fn every_tool_has_a_meaningful_concise_title() {
261 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
262 for tool in &tools {
263 let title = tool
264 .title
265 .as_deref()
266 .unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name));
267 assert!(!title.trim().is_empty(), "{} has a blank title", tool.name);
268 assert!(
269 title.len() <= 40,
270 "{} title should be concise (<= 40 chars), got {title:?}",
271 tool.name
272 );
273 assert_ne!(
274 title,
275 tool.name.as_ref(),
276 "{} title should be a human-readable label, not the raw tool name",
277 tool.name
278 );
279 }
280 }
281
282 #[test]
283 fn every_tool_has_a_front_loaded_transparent_description() {
284 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
285 for tool in &tools {
286 let description = tool
287 .description
288 .as_deref()
289 .unwrap_or_else(|| panic!("{} is missing a description", tool.name));
290
291 for required in [
292 "Purpose:",
293 "When to use",
294 "Behavior:",
295 "Prerequisites:",
296 "Failure modes:",
297 "Limitations:",
298 ] {
299 assert!(
300 description.contains(required),
301 "{} description is missing the {required:?} section",
302 tool.name
303 );
304 }
305 assert!(
306 description.starts_with("Purpose:"),
307 "{} description should front-load its purpose",
308 tool.name
309 );
310 // Loosely bounded rather than snapshotted, so wording can
311 // evolve without a brittle exact-text assertion.
312 assert!(
313 description.len() >= 150,
314 "{} description is too terse for behavioral transparency ({} chars)",
315 tool.name,
316 description.len()
317 );
318 }
319 }
320
321 #[test]
322 fn every_tool_has_internally_consistent_annotations() {
323 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
324 for tool in &tools {
325 let annotations = tool
326 .annotations
327 .as_ref()
328 .unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
329 let annotations_title = annotations
330 .title
331 .as_deref()
332 .unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name));
333 assert_eq!(
334 Some(annotations_title),
335 tool.title.as_deref(),
336 "{} top-level title and annotations title must agree",
337 tool.name
338 );
339 assert!(
340 !(annotations.read_only_hint == Some(true)
341 && annotations.destructive_hint == Some(true)),
342 "{} annotations contradict: read_only_hint and destructive_hint are both true",
343 tool.name
344 );
345 }
346 }
347
348 #[test]
349 fn every_input_schema_property_has_a_description() {
350 let tools = AscapiMcpServer::ascapi_tool_router().list_all();
351 for tool in &tools {
352 let Some(properties) = tool
353 .input_schema
354 .get("properties")
355 .and_then(|value| value.as_object())
356 else {
357 continue;
358 };
359 for (property_name, property_schema) in properties {
360 let description = property_schema
361 .get("description")
362 .and_then(|value| value.as_str())
363 .unwrap_or_default();
364 assert!(
365 !description.trim().is_empty(),
366 "{}.{} is missing a parameter description",
367 tool.name,
368 property_name
369 );
370 }
371 }
372 }
373 }