use crate::ascapi_mcp_tools;
use anyhow::{anyhow, Result};
use rmcp::{
model::{Implementation, ServerCapabilities, ServerInfo},
transport::stdio,
ServerHandler, ServiceExt,
};
#[derive(Debug, Clone, Default)]
pub struct AscapiMcpServer;
impl AscapiMcpServer {
pub fn new() -> Self {
Self
}
}
ascapi_mcp_tools!(
AscapiMcpServer,
"app_list",
"app_get",
"app_update",
"app_info_list",
"build_list",
"bundle_id_list",
"bundle_id_create",
"app_store_version_list",
"app_store_version_get",
"app_store_version_create",
"app_store_version_delete",
"app_store_version_set_build",
"app_store_version_build_get",
"app_info_localization_list",
"app_info_localization_create",
"app_info_localization_update",
"app_info_localization_delete",
"app_store_version_localization_list",
"app_store_version_localization_create",
"app_store_version_localization_update",
"app_store_version_localization_delete",
"app_screenshot_set_list",
"app_screenshot_set_create",
"app_screenshot_set_delete",
"app_screenshot_list",
"app_screenshot_upload",
"app_screenshot_delete",
"certificate_list",
"certificate_create",
"beta_group_list",
"beta_app_review_detail_get",
"play_listing_list",
"play_images_list",
"play_track_get"
);
#[rmcp::tool_handler(router = Self::ascapi_tool_router())]
impl ServerHandler for AscapiMcpServer {
fn get_info(&self) -> ServerInfo {
let mut implementation = Implementation::from_build_env();
implementation.name = "ascapi".to_string();
implementation.version = env!("CARGO_PKG_VERSION").to_string();
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(implementation)
.with_instructions(
"ascapi exposes App Store Connect signing certificates (certificate_list, \
certificate_create). Certificates are issued by generating an RSA 2048 key \
pair locally and sending only a signing request to Apple, so the private key \
never leaves this machine and Apple keeps no copy of it: a lost key file \
makes its certificate permanently unusable. Revocation is deliberately not \
offered here, because revoking a distribution certificate invalidates every \
provisioning profile embedding it for the whole team at once; run \
`asc certificates revoke` from a terminal instead. Requires ASC_API_KEY, \
ASC_ISSUER_ID, and a readable .p8 key.",
)
}
}
pub async fn serve() -> Result<()> {
let running = AscapiMcpServer::new()
.serve(stdio())
.await
.map_err(|error| anyhow!("Failed to start ascapi MCP server: {error}"))?;
running
.waiting()
.await
.map_err(|error| anyhow!("ascapi MCP server stopped unexpectedly: {error}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL_TOOL_NAMES: [&str; 34] = [
"app_list",
"app_get",
"app_update",
"app_info_list",
"build_list",
"bundle_id_list",
"bundle_id_create",
"app_store_version_list",
"app_store_version_get",
"app_store_version_create",
"app_store_version_delete",
"app_store_version_set_build",
"app_store_version_build_get",
"app_info_localization_list",
"app_info_localization_create",
"app_info_localization_update",
"app_info_localization_delete",
"app_store_version_localization_list",
"app_store_version_localization_create",
"app_store_version_localization_update",
"app_store_version_localization_delete",
"app_screenshot_set_list",
"app_screenshot_set_create",
"app_screenshot_set_delete",
"app_screenshot_list",
"app_screenshot_upload",
"app_screenshot_delete",
"certificate_list",
"certificate_create",
"beta_group_list",
"beta_app_review_detail_get",
"play_listing_list",
"play_images_list",
"play_track_get",
];
const FORBIDDEN_TOOL_NAMES: [&str; 4] = [
"certificate_revoke",
"certificate_delete",
"certificate_nuke",
"app_delete",
];
#[test]
fn tool_router_exposes_exactly_the_canonical_tools() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
assert_eq!(
tools.len(),
CANONICAL_TOOL_NAMES.len(),
"expected exactly {} canonical tools, found {}",
CANONICAL_TOOL_NAMES.len(),
tools.len()
);
let mut actual: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
actual.sort_unstable();
let mut expected = CANONICAL_TOOL_NAMES;
expected.sort_unstable();
assert_eq!(actual, expected);
}
#[test]
fn no_irreversible_tool_is_ever_exposed() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
assert!(
!FORBIDDEN_TOOL_NAMES.contains(&tool.name.as_ref()),
"{} is irreversible or team-wide and must not be reachable over MCP",
tool.name
);
}
}
#[test]
fn every_delete_tool_is_annotated_destructive() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
let deletes: Vec<_> = tools
.iter()
.filter(|tool| tool.name.ends_with("_delete"))
.collect();
assert!(
!deletes.is_empty(),
"expected some delete tools; if they were removed, drop this test with them"
);
for tool in deletes {
let annotations = tool
.annotations
.as_ref()
.unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
assert_eq!(
annotations.destructive_hint,
Some(true),
"{} deletes something and must be annotated destructive",
tool.name
);
}
}
#[test]
fn read_only_tools_are_never_annotated_destructive() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
let Some(annotations) = tool.annotations.as_ref() else {
continue;
};
if annotations.read_only_hint == Some(true) {
assert_ne!(
annotations.destructive_hint,
Some(true),
"{} claims to be read-only and destructive at once",
tool.name
);
}
}
}
#[test]
fn tool_names_carry_no_server_prefix() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
assert!(
!tool.name.starts_with("ascapi_") && !tool.name.starts_with("smb_"),
"{} should not repeat a server prefix",
tool.name
);
}
}
#[test]
fn every_tool_has_a_meaningful_concise_title() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
let title = tool
.title
.as_deref()
.unwrap_or_else(|| panic!("{} is missing a top-level title", tool.name));
assert!(!title.trim().is_empty(), "{} has a blank title", tool.name);
assert!(
title.len() <= 40,
"{} title should be concise (<= 40 chars), got {title:?}",
tool.name
);
assert_ne!(
title,
tool.name.as_ref(),
"{} title should be a human-readable label, not the raw tool name",
tool.name
);
}
}
#[test]
fn every_tool_has_a_front_loaded_transparent_description() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
let description = tool
.description
.as_deref()
.unwrap_or_else(|| panic!("{} is missing a description", tool.name));
for required in [
"Purpose:",
"When to use",
"Behavior:",
"Prerequisites:",
"Failure modes:",
"Limitations:",
] {
assert!(
description.contains(required),
"{} description is missing the {required:?} section",
tool.name
);
}
assert!(
description.starts_with("Purpose:"),
"{} description should front-load its purpose",
tool.name
);
assert!(
description.len() >= 150,
"{} description is too terse for behavioral transparency ({} chars)",
tool.name,
description.len()
);
}
}
#[test]
fn every_tool_has_internally_consistent_annotations() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
let annotations = tool
.annotations
.as_ref()
.unwrap_or_else(|| panic!("{} is missing annotations", tool.name));
let annotations_title = annotations
.title
.as_deref()
.unwrap_or_else(|| panic!("{} annotations are missing a title", tool.name));
assert_eq!(
Some(annotations_title),
tool.title.as_deref(),
"{} top-level title and annotations title must agree",
tool.name
);
assert!(
!(annotations.read_only_hint == Some(true)
&& annotations.destructive_hint == Some(true)),
"{} annotations contradict: read_only_hint and destructive_hint are both true",
tool.name
);
}
}
#[test]
fn every_input_schema_property_has_a_description() {
let tools = AscapiMcpServer::ascapi_tool_router().list_all();
for tool in &tools {
let Some(properties) = tool
.input_schema
.get("properties")
.and_then(|value| value.as_object())
else {
continue;
};
for (property_name, property_schema) in properties {
let description = property_schema
.get("description")
.and_then(|value| value.as_str())
.unwrap_or_default();
assert!(
!description.trim().is_empty(),
"{}.{} is missing a parameter description",
tool.name,
property_name
);
}
}
}
}