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_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
85/// Serve over stdio until the client disconnects.
86///
87/// stdout carries the JSON-RPC stream from here on, so nothing in this
88/// path may print to it. Diagnostics belong on stderr.
89pub 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    /// The canonical tool names. Adding or renaming one is a
106    /// public-contract change, and this list is what makes that
107    /// deliberate rather than accidental.
108    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    /// Never a tool, at any point, for any caller.
140    ///
141    /// Revoking a signing certificate invalidates every provisioning
142    /// profile embedding it, for every teammate and every CI job, at once
143    /// and irreversibly. No confirmation string a model types on a human's
144    /// behalf makes that safe. It lives on the command line only, where a
145    /// person is the one at the keyboard.
146    ///
147    /// The scoped deletes below are a different matter and *are* exposed:
148    /// a version, a localization, a screenshot set, or an image can all be
149    /// recreated by re-running the tool that made them. They carry
150    /// `destructive_hint` so a client can gate them on its own terms.
151    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        // The naming convention is load-bearing: a client deciding whether
192        // to prompt reads the annotation, not the name, so the two must
193        // agree or a delete slips through a confirmation gate.
194        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            // Loosely bounded rather than snapshotted, so wording can
299            // evolve without a brittle exact-text assertion.
300            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}