feat: exempt smbCloud read tools from permission prompts
The official server's query tools already run without prompting; give the baked-in smbCloud CLI server the same treatment for its read-only tools (me, deployments, project_list, project_show). Unlike the official server's list_/get_ name-shape check, this is an explicit allow-list, since smb's tool names follow no such convention. A name the list doesn't know (say, from a newer smb release) fails safe to mutating, and the exemption only applies when smbcloud is the whole server name, so a foreign server can't borrow it.
paydii committed
Jul 19, 2026 at 14:00 UTC
e928a35a8e009696986ae82ba1d109d1a7e13fc0
2 files changed
+73
-6
src/mcp.rs
+21
@@ -110,6 +110,14 @@ const SMBCLOUD_SERVER_NAME: &str = "smbcloud";
110
/// The smbCloud CLI binary the baked-in stdio entry spawns (`smb --mcp`).
111
const SMBCLOUD_COMMAND: &str = "smb";
112
113
+/// The bare tool name when `name` belongs to the smbCloud server
114
+/// (`mcp__smbcloud__project_list` → `Some("project_list")`), else `None`.
115
+pub fn smbcloud_tool_suffix(name: &str) -> Option<&str> {
116
+ name.strip_prefix(MCP_PREFIX)?
117
+ .strip_prefix(SMBCLOUD_SERVER_NAME)?
118
+ .strip_prefix("__")
119
+}
120
+
121
/// JSON-RPC / MCP protocol version we advertise in the handshake.
122
const PROTOCOL_VERSION: &str = "2025-06-18";
123
@@ -1371,6 +1379,19 @@ mod tests {
1379
assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
1380
}
1381
1382
+ #[test]
1383
+ fn smbcloud_tool_suffix_strips_only_the_smbcloud_namespace() {
1384
+ assert_eq!(
1385
+ smbcloud_tool_suffix("mcp__smbcloud__project_list"),
1386
+ Some("project_list")
1387
+ );
1388
+ assert_eq!(smbcloud_tool_suffix("mcp__sigit__project_list"), None);
1389
+ // `smbcloud` must be the whole server name, not a prefix of it.
1390
+ assert_eq!(smbcloud_tool_suffix("mcp__smbcloudx__project_list"), None);
1391
+ assert_eq!(smbcloud_tool_suffix("project_list"), None);
1392
+ assert_eq!(smbcloud_tool_suffix("mcp__smbcloud__"), Some(""));
1393
+ }
1394
+
1395
#[test]
1396
fn sanitize_collapses_invalid_chars() {
1397
assert_eq!(sanitize("github"), "github");
src/permissions.rs
+52
-6
@@ -40,8 +40,9 @@
40
//!
41
//! Tools discovered from MCP servers (`mcp__*`) and any unknown tool name are
42
//! treated as mutating: external tools can have arbitrary side effects, so the
43
-//! safe assumption is to gate them. The one exception is the official
44
-//! sigit.si server's query tools (see [`classify`]), which are read-only.
43
+//! safe assumption is to gate them. The exceptions are the query tools of the
44
+//! two baked-in servers — the official sigit.si server and the smbCloud CLI —
45
+//! which are read-only (see [`classify`]).
46
//!
47
//! Session state (grants + plan mode) lives in a process-global keyed by
48
//! session id — the same pattern as `mcp.rs`'s server cache — so the ACP
@@ -94,10 +95,14 @@ pub enum Decision {
95
/// `tools.rs`'s `web_search` module doc for why it isn't classified by that
96
/// `mcp__` prefix).
97
///
97
-/// One MCP exception: the *official* sigit.si server (`mcp__sigit__*`) is
98
-/// first-party, so its query tools — names starting `list_` or `get_`, plus
99
-/// `search_code` and `web_search` — are read-only and never prompt (the TUI's
100
-/// Repo tab depends on this). Every other `mcp__*` tool stays mutating.
98
+/// Two MCP exceptions, both baked-in first-party servers. The *official*
99
+/// sigit.si server's (`mcp__sigit__*`) query tools — names starting `list_`
100
+/// or `get_`, plus `search_code` and `web_search` — are read-only and never
101
+/// prompt (the TUI's Repo tab depends on this). The smbCloud CLI server's
102
+/// (`mcp__smbcloud__*`) read tools are exempted by an explicit allow-list
103
+/// rather than a name shape, since its names don't follow the `list_`/`get_`
104
+/// convention — a tool the list doesn't know (e.g. from a newer smb) fails
105
+/// safe to mutating. Every other `mcp__*` tool stays mutating.
106
pub fn classify(tool_name: &str) -> ToolRisk {
107
match tool_name {
108
"read_file" | "list_directory" | "search_files" | "glob" | "read_website"
@@ -111,6 +116,11 @@ pub fn classify(tool_name: &str) -> ToolRisk {
116
{
117
return ToolRisk::ReadOnly;
118
}
119
+ if let Some(bare) = crate::mcp::smbcloud_tool_suffix(tool_name)
120
+ && matches!(bare, "me" | "deployments" | "project_list" | "project_show")
121
+ {
122
+ return ToolRisk::ReadOnly;
123
+ }
124
ToolRisk::Mutating
125
}
126
}
@@ -505,6 +515,42 @@ mod tests {
515
}
516
}
517
518
+ #[test]
519
+ fn smbcloud_mcp_read_tools_are_read_only() {
520
+ let _guard = env_guard();
521
+ for tool in [
522
+ "mcp__smbcloud__me",
523
+ "mcp__smbcloud__deployments",
524
+ "mcp__smbcloud__project_list",
525
+ "mcp__smbcloud__project_show",
526
+ ] {
527
+ assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
528
+ assert_eq!(
529
+ decision_for("t-smbcloud", tool, "{}"),
530
+ Decision::Allow,
531
+ "{tool}"
532
+ );
533
+ }
534
+ }
535
+
536
+ #[test]
537
+ fn smbcloud_mcp_mutating_and_unknown_tools_stay_gated() {
538
+ for tool in [
539
+ // smbcloud server, but not on the read allow-list
540
+ "mcp__smbcloud__project_create",
541
+ "mcp__smbcloud__project_update",
542
+ "mcp__smbcloud__project_delete",
543
+ "mcp__smbcloud__",
544
+ // allow-listed names on other servers get no exemption
545
+ "mcp__other__project_list",
546
+ "mcp__other__me",
547
+ // `smbcloud` must match the whole server name
548
+ "mcp__smbcloudx__project_list",
549
+ ] {
550
+ assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
551
+ }
552
+ }
553
+
554
#[test]
555
fn read_only_tool_still_honors_an_explicit_deny() {
556
let _guard = env_guard();