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