Skip to main content

smbcloud_ascapi_mcp/
server.rs

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
13use 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    "play_listing_list",
64    "play_images_list",
65    "play_track_get"
66);
67
68#[rmcp::tool_handler(router = Self::ascapi_tool_router())]
69impl 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.
95pub 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)]
108mod 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}