@sigitcode-old / sigit / commits / 60daa48
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

Bake ascapi (`asc --mcp`) into siGit as a third MCP server

`smbcloud-ascapi` ships an MCP stdio server reachable as `asc --mcp`, and the `asc` binary is on `PATH` for any user with the CLI installed (`cargo install --path crates/cli --locked` from the ascapi repo, or a prebuilt). Surface it the same way the `smbcloud` and `sigit` baked-in servers are surfaced: discover via the `asc` binary, namespace tools `mcp__ascapi__*`, and let the user opt out either per-config (`ascapi = false` in mcp.toml) or per-process (`SIGIT_MCP_ASCAPI=off`). `mcp.rs` adds `ASCAPI_SERVER_NAME` ("ascapi"), `ASCAPI_COMMAND` ("asc"), and an `ascapi_tool_suffix` helper that strips only the `ascapi` namespace — the same shape as `smbcloud_tool_suffix`. The config loader reads `McpFile.ascapi` (the same `Option<bool>` field shape as `smbcloud`), checks the env opt-out, and only adds the server when `asc` is actually on `PATH`. `permissions.rs` makes the 14 read-only ascapi tools (`app_list`, `app_get`, `app_info_list`, `build_list`, `bundle_id_list`, `app_store_version_list`, `app_store_version_get`, `app_info_localization_list`, `app_store_version_localization_list`, `app_screenshot_set_list`, `app_screenshot_list`, `certificate_list`, `beta_group_list`, `beta_app_review_detail_get`) classify as `ReadOnly` — everything else under `mcp__ascapi__*` (including unknown/empty suffixes) stays `Mutating`, same as `mcp__smbcloud__*` and the rest of the `mcp__*` namespace. The allow-list is explicit: no `list_`/`get_` matching, so a destructive tool whose name happens to start with `list_` cannot sneak through. `tests/mcp_stdio.rs` extends the per-server skip list with `ascapi = false\n` so the stdio-loopback test stays hermetic on machines where the user has installed the ascapi CLI. Drive-by: the "Three MCP exceptions" section in both module docs is rephrased so the `ascapi` paragraph reads as part of the same group as `smbcloud` (and `web_search`), and `mcp__ascapix__*` (a hypothetical neighbouring namespace) is explicitly named as `Mutating`, matching the whole-server-name-match rule that already covers `mcp__smbcloud__*`. Co-Authored-By: siGit Code v1.6.8-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 13, 2026 at 21:42 UTC 60daa481496bd0f90cd6717cd808d0b6cc815b9a
3 files changed +173 -5
src/mcp.rs
+84 -1
@@ -31,7 +31,11 @@
31 //! error. Opt out with `smbcloud = false` in `mcp.toml` or
32 //! `SIGIT_MCP_SMBCLOUD=off`.
33 //!
34 -//! Both baked-in entries yield to a user-defined `mcp.toml` server of the same
34 +//! The App Store Connect API CLI's MCP server (`asc --mcp`, stdio) is baked
35 +//! in the same way: only when the `asc` binary is on `PATH`, opt out with
36 +//! `ascapi = false` in `mcp.toml` or `SIGIT_MCP_ASCAPI=off`.
37 +//!
38 +//! All baked-in entries yield to a user-defined `mcp.toml` server of the same
39 //! name, so either can be repointed or reconfigured without a special case.
40 //! Additional servers are configured in `mcp.toml` (see [`load_configs`]).
41 //!
@@ -115,6 +119,24 @@ pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
119 .strip_prefix("__")
120 }
121
122 +/// Name of the baked-in App Store Connect API CLI server; its tools are
123 +/// namespaced `mcp__ascapi__<tool>`. Like the other baked-in servers, a
124 +/// user-defined `mcp.toml` entry with this name overrides the baked-in
125 +/// command line.
126 +const ASCAPI_SERVER_NAME: &str = "ascapi";
127 +
128 +/// The App Store Connect API CLI binary the baked-in stdio entry spawns
129 +/// (`asc --mcp`).
130 +const ASCAPI_COMMAND: &str = "asc";
131 +
132 +/// The bare tool name when `name` belongs to the ascapi server
133 +/// (`mcp__ascapi__app_list` → `Some("app_list")`), else `None`.
134 +pub fn ascapi_tool_suffix(name: &str) -> Option<&str> {
135 + name.strip_prefix(MCP_PREFIX)?
136 + .strip_prefix(ASCAPI_SERVER_NAME)?
137 + .strip_prefix("__")
138 +}
139 +
140 /// Per-server budget for the connect + `initialize` + `tools/list` handshake at
141 /// startup. Bounds how long an unreachable server can delay startup; servers are
142 /// contacted concurrently, so this is the worst case for the whole set, not the
@@ -257,6 +279,11 @@ struct McpFile {
279 /// `true`; set `false` to opt out. Moot when `smb` isn't installed.
280 #[serde(default)]
281 smbcloud: Option<bool>,
282 + /// Include the baked-in App Store Connect API CLI server (`asc --mcp`).
283 + /// Defaults to `true`; set `false` to opt out. Moot when `asc` isn't
284 + /// installed.
285 + #[serde(default)]
286 + ascapi: Option<bool>,
287 #[serde(default)]
288 server: Vec<ServerEntry>,
289 }
@@ -375,6 +402,7 @@ fn load_configs() -> Vec<ServerDef> {
402
403 let mut include_official = true;
404 let mut include_smbcloud = true;
405 + let mut include_ascapi = true;
406 // De-duplicated by sanitized name; a later config file overrides an earlier
407 // one for the same name (project-local wins over global).
408 let mut defs: Vec<ServerDef> = Vec::new();
@@ -396,6 +424,9 @@ fn load_configs() -> Vec<ServerDef> {
424 if let Some(smbcloud) = parsed.smbcloud {
425 include_smbcloud = smbcloud;
426 }
427 + if let Some(ascapi) = parsed.ascapi {
428 + include_ascapi = ascapi;
429 + }
430 for entry in parsed.server {
431 if entry.enabled == Some(false) {
432 continue;
@@ -474,6 +505,36 @@ fn load_configs() -> Vec<ServerDef> {
505 }
506 }
507
508 + // The ascapi server can also be disabled with SIGIT_MCP_ASCAPI=off.
509 + if let Ok(value) = std::env::var("SIGIT_MCP_ASCAPI")
510 + && matches!(
511 + value.trim().to_ascii_lowercase().as_str(),
512 + "off" | "0" | "false" | "no"
513 + )
514 + {
515 + include_ascapi = false;
516 + }
517 +
518 + // Add the baked-in ascapi server (`asc --mcp`), with the same
519 + // never-clobber rule as the others. Only when `asc` is actually
520 + // installed: a hardwired entry for a binary most users don't have
521 + // would surface a spawn failure in `/mcp` instead of just staying out
522 + // of the way.
523 + if include_ascapi && !defs.iter().any(|d| d.name == ASCAPI_SERVER_NAME) {
524 + if on_path(ASCAPI_COMMAND) {
525 + defs.push(ServerDef {
526 + name: ASCAPI_SERVER_NAME.to_string(),
527 + transport: TransportDef::Stdio {
528 + command: ASCAPI_COMMAND.to_string(),
529 + args: vec!["--mcp".to_string()],
530 + env: Vec::new(),
531 + },
532 + });
533 + } else {
534 + log::debug!("mcp: `{ASCAPI_COMMAND}` not on PATH; skipping the ascapi server");
535 + }
536 + }
537 +
538 defs
539 }
540
@@ -1000,6 +1061,19 @@ mod tests {
1061 assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1062 }
1063
1064 + #[test]
1065 + fn ascapi_tool_suffix_strips_only_the_ascapi_namespace() {
1066 + assert_eq!(
1067 + ascapi_tool_suffix("mcp__ascapi__app_list"),
1068 + Some("app_list")
1069 + );
1070 + assert_eq!(ascapi_tool_suffix("mcp__sigit__app_list"), None);
1071 + // `ascapi` must be the whole server name, not a prefix of it.
1072 + assert_eq!(ascapi_tool_suffix("mcp__ascapix__app_list"), None);
1073 + assert_eq!(ascapi_tool_suffix("app_list"), None);
1074 + assert_eq!(ascapi_tool_suffix("mcp__ascapi__"), Some(""));
1075 + }
1076 +
1077 #[test]
1078 fn sanitize_collapses_invalid_chars() {
1079 assert_eq!(sanitize("github"), "github");
@@ -1049,6 +1123,15 @@ mod tests {
1123 assert_eq!(parsed.smbcloud, None);
1124 }
1125
1126 + #[test]
1127 + fn parses_ascapi_opt_out_flag() {
1128 + let parsed: McpFile = toml::from_str("ascapi = false").unwrap();
1129 + assert_eq!(parsed.ascapi, Some(false));
1130 + // Absent means "include" (the default stays true in load_configs).
1131 + let parsed: McpFile = toml::from_str("").unwrap();
1132 + assert_eq!(parsed.ascapi, None);
1133 + }
1134 +
1135 #[test]
1136 fn on_path_finds_real_binaries_only() {
1137 assert!(!on_path("definitely-not-a-real-binary-xyzzy"));
src/permissions.rs
+87 -2
@@ -100,14 +100,20 @@ pub enum Decision {
100 /// `tools.rs`'s `web_search` module doc for why it isn't classified by that
101 /// `mcp__` prefix).
102 ///
103 -/// Two MCP exceptions, both baked-in first-party servers. The *official*
103 +/// Three MCP exceptions, all baked-in first-party servers. The *official*
104 /// sigit.si server's (`mcp__sigit__*`) query tools — names starting `list_`
105 /// or `get_`, plus `search_code` and `web_search` — are read-only and never
106 /// prompt (the TUI's Repo tab depends on this). The smbCloud CLI server's
107 /// (`mcp__smbcloud__*`) read tools are exempted by an explicit allow-list
108 /// rather than a name shape, since its names don't follow the `list_`/`get_`
109 /// convention — a tool the list doesn't know (e.g. from a newer smb) fails
110 -/// safe to mutating. Every other `mcp__*` tool stays mutating.
110 +/// safe to mutating. The `ascapi` App Store Connect CLI server
111 +/// (`mcp__ascapi__*`) works the same way as the smbCloud one: a fixed
112 +/// allow-list of the read-only `asc apps *`/`asc certificates list`/etc.
113 +/// tool names exempts them, and any other name on that server (or an empty
114 +/// one, or a name on a different-but-similar server like
115 +/// `mcp__ascapix__*`) stays mutating. Every other `mcp__*` tool stays
116 +/// mutating.
117 pub fn classify(tool_name: &str) -> ToolRisk {
118 match tool_name {
119 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
@@ -126,6 +132,27 @@ pub fn classify(tool_name: &str) -> ToolRisk {
132 {
133 return ToolRisk::ReadOnly;
134 }
135 + if let Some(bare) = crate::mcp::ascapi_tool_suffix(tool_name)
136 + && matches!(
137 + bare,
138 + "app_list"
139 + | "app_get"
140 + | "app_info_list"
141 + | "build_list"
142 + | "bundle_id_list"
143 + | "app_store_version_list"
144 + | "app_store_version_get"
145 + | "app_info_localization_list"
146 + | "app_store_version_localization_list"
147 + | "app_screenshot_set_list"
148 + | "app_screenshot_list"
149 + | "certificate_list"
150 + | "beta_group_list"
151 + | "beta_app_review_detail_get"
152 + )
153 + {
154 + return ToolRisk::ReadOnly;
155 + }
156 ToolRisk::Mutating
157 }
158 }
@@ -601,6 +628,64 @@ mod tests {
628 }
629 }
630
631 + #[test]
632 + fn ascapi_mcp_read_tools_are_read_only() {
633 + let _guard = env_guard();
634 + for tool in [
635 + "mcp__ascapi__app_list",
636 + "mcp__ascapi__app_get",
637 + "mcp__ascapi__app_info_list",
638 + "mcp__ascapi__build_list",
639 + "mcp__ascapi__bundle_id_list",
640 + "mcp__ascapi__app_store_version_list",
641 + "mcp__ascapi__app_store_version_get",
642 + "mcp__ascapi__app_info_localization_list",
643 + "mcp__ascapi__app_store_version_localization_list",
644 + "mcp__ascapi__app_screenshot_set_list",
645 + "mcp__ascapi__app_screenshot_list",
646 + "mcp__ascapi__certificate_list",
647 + "mcp__ascapi__beta_group_list",
648 + "mcp__ascapi__beta_app_review_detail_get",
649 + ] {
650 + assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
651 + assert_eq!(
652 + decision_for("t-ascapi", tool, "{}"),
653 + Decision::Allow,
654 + "{tool}"
655 + );
656 + }
657 + }
658 +
659 + #[test]
660 + fn ascapi_mcp_mutating_and_unknown_tools_stay_gated() {
661 + for tool in [
662 + // ascapi server, but not on the read allow-list
663 + "mcp__ascapi__app_create",
664 + "mcp__ascapi__app_update",
665 + "mcp__ascapi__app_delete",
666 + "mcp__ascapi__upload",
667 + "mcp__ascapi__review_submissions_submit",
668 + "mcp__ascapi__certificate_revoke",
669 + "mcp__ascapi__profiles_create",
670 + "mcp__ascapi__profiles_delete",
671 + "mcp__ascapi__devices_register",
672 + "mcp__ascapi__bundle_id_delete",
673 + "mcp__ascapi__beta_group_delete",
674 + "mcp__ascapi__beta_tester_invite",
675 + "mcp__ascapi__app_store_version_submit",
676 + "mcp__ascapi__pre_release_version_delete",
677 + "mcp__ascapi__build_expire",
678 + "mcp__ascapi__app_screenshot_delete",
679 + // empty/unknown names on the ascapi server
680 + "mcp__ascapi__",
681 + "mcp__ascapi__not_a_real_tool",
682 + // `ascapi` must match the whole server name
683 + "mcp__ascapix__app_list",
684 + ] {
685 + assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
686 + }
687 + }
688 +
689 #[test]
690 fn read_only_tool_still_honors_an_explicit_deny() {
691 let _guard = env_guard();
tests/mcp_stdio.rs
+2 -2
@@ -398,7 +398,7 @@ fn client_supplied_stdio_server_is_scoped_to_its_session() {
398 // client.
399 std::fs::write(
400 config_dir.join("mcp.toml"),
401 - "official = false\nsmbcloud = false\n",
401 + "official = false\nsmbcloud = false\nascapi = false\n",
402 )
403 .unwrap();
404
@@ -613,7 +613,7 @@ fn client_supplied_http_server_is_connected() {
613 std::fs::create_dir_all(&cwd).unwrap();
614 std::fs::write(
615 config_dir.join("mcp.toml"),
616 - "official = false\nsmbcloud = false\n",
616 + "official = false\nsmbcloud = false\nascapi = false\n",
617 )
618 .unwrap();
619