Skip to main content

mur_common/skill/
mcp.rs

1//! MCP capability declaration vocabulary (M6a).
2//!
3//! Shared string-form contract between skill manifests and commander's
4//! MCP trust store. `mur-common` owns the enum so the YAML schema is
5//! stable even if commander refactors internally.
6//!
7//! If commander adds a 7th capability, add the variant + `as_str`/`FromStr`
8//! arms here with no other code changes needed.
9
10use serde::{Deserialize, Serialize};
11use std::str::FromStr;
12
13/// MCP capabilities a skill may declare it requires. Mirrors the six
14/// capabilities defined in mur-commander's `engine/src/mcp/trust.rs`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
16pub enum SkillCapability {
17    ReadFile,
18    ListTools,
19    Search,
20    WriteFile,
21    ExecuteSafe,
22    NetworkHttp,
23}
24
25impl SkillCapability {
26    pub const ALL: &[SkillCapability] = &[
27        SkillCapability::ReadFile,
28        SkillCapability::ListTools,
29        SkillCapability::Search,
30        SkillCapability::WriteFile,
31        SkillCapability::ExecuteSafe,
32        SkillCapability::NetworkHttp,
33    ];
34
35    pub fn as_str(self) -> &'static str {
36        match self {
37            SkillCapability::ReadFile => "read_file",
38            SkillCapability::ListTools => "list_tools",
39            SkillCapability::Search => "search",
40            SkillCapability::WriteFile => "write_file",
41            SkillCapability::ExecuteSafe => "execute_safe",
42            SkillCapability::NetworkHttp => "network_http",
43        }
44    }
45}
46
47impl std::fmt::Display for SkillCapability {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.write_str(self.as_str())
50    }
51}
52
53impl FromStr for SkillCapability {
54    type Err = ParseCapabilityError;
55
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        Ok(match s {
58            "read_file" => SkillCapability::ReadFile,
59            "list_tools" => SkillCapability::ListTools,
60            "search" => SkillCapability::Search,
61            "write_file" => SkillCapability::WriteFile,
62            "execute_safe" => SkillCapability::ExecuteSafe,
63            "network_http" => SkillCapability::NetworkHttp,
64            other => return Err(ParseCapabilityError(other.to_string())),
65        })
66    }
67}
68
69#[derive(Debug, thiserror::Error)]
70#[error(
71    "unknown MCP capability '{0}' (expected one of: read_file, list_tools, search, \
72     write_file, execute_safe, network_http)"
73)]
74pub struct ParseCapabilityError(pub String);
75
76// Serde uses the string form so YAML reads as `capability: read_file`,
77// not `capability: ReadFile`.
78impl Serialize for SkillCapability {
79    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
80        s.serialize_str(self.as_str())
81    }
82}
83
84impl<'de> Deserialize<'de> for SkillCapability {
85    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
86        let s = String::deserialize(d)?;
87        s.parse().map_err(serde::de::Error::custom)
88    }
89}
90
91/// A requirement that one or more MCP tools matching `tool_pattern` be
92/// reachable at runtime, and that they are safe to invoke under `capability`.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct McpRequirement {
95    /// Glob pattern matching tool names, e.g. `"browser.*"` or
96    /// `"filesystem.write.*"`.
97    pub tool_pattern: String,
98
99    /// Capability for the matched tool. Used by commander's trust store
100    /// at runtime (M6b) to decide whether to permit the call.
101    pub capability: SkillCapability,
102
103    /// Optional fallback. Empty string means "no fallback — fail if no
104    /// matching tool is available". Free-form; not validated here.
105    #[serde(default, skip_serializing_if = "String::is_empty")]
106    pub fallback: String,
107}
108
109/// Validate a list of requirements at parse time.
110///
111/// Checks: non-empty pattern, valid glob syntax, no duplicate
112/// (pattern, capability) pairs.
113pub fn validate_requirements(reqs: &[McpRequirement]) -> Result<(), (usize, String)> {
114    use std::collections::HashSet;
115    let mut seen: HashSet<(&str, &str)> = HashSet::new();
116    for (i, req) in reqs.iter().enumerate() {
117        if req.tool_pattern.is_empty() {
118            return Err((i, "tool_pattern must not be empty".into()));
119        }
120        // Validate glob syntax.
121        if globset::Glob::new(&req.tool_pattern).is_err() {
122            return Err((i, format!("invalid glob pattern: '{}'", req.tool_pattern)));
123        }
124        let cap_str = req.capability.as_str();
125        if !seen.insert((&req.tool_pattern, cap_str)) {
126            return Err((
127                i,
128                format!(
129                    "duplicate (tool_pattern, capability) pair: '{}'/{}",
130                    req.tool_pattern, req.capability
131                ),
132            ));
133        }
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn parse_valid_capabilities() {
144        assert_eq!(
145            "read_file".parse::<SkillCapability>().unwrap(),
146            SkillCapability::ReadFile
147        );
148        assert_eq!(
149            "list_tools".parse::<SkillCapability>().unwrap(),
150            SkillCapability::ListTools
151        );
152        assert_eq!(
153            "search".parse::<SkillCapability>().unwrap(),
154            SkillCapability::Search
155        );
156        assert_eq!(
157            "write_file".parse::<SkillCapability>().unwrap(),
158            SkillCapability::WriteFile
159        );
160        assert_eq!(
161            "execute_safe".parse::<SkillCapability>().unwrap(),
162            SkillCapability::ExecuteSafe
163        );
164        assert_eq!(
165            "network_http".parse::<SkillCapability>().unwrap(),
166            SkillCapability::NetworkHttp
167        );
168    }
169
170    #[test]
171    fn parse_invalid_capability() {
172        let err = "telepathy".parse::<SkillCapability>().unwrap_err();
173        assert!(err.to_string().contains("unknown MCP capability"));
174        assert!(err.to_string().contains("telepathy"));
175    }
176
177    #[test]
178    fn capability_display_roundtrips() {
179        for cap in SkillCapability::ALL {
180            let s = cap.to_string();
181            let parsed: SkillCapability = s.parse().unwrap();
182            assert_eq!(parsed, *cap);
183        }
184    }
185
186    #[test]
187    fn capability_serializes_as_string() {
188        let json = serde_json::to_string(&SkillCapability::ReadFile).unwrap();
189        assert_eq!(json, "\"read_file\"");
190    }
191
192    #[test]
193    fn capability_deserializes_from_string() {
194        let cap: SkillCapability = serde_json::from_str("\"network_http\"").unwrap();
195        assert_eq!(cap, SkillCapability::NetworkHttp);
196    }
197
198    #[test]
199    fn capability_rejects_unknown_string() {
200        let err = serde_json::from_str::<SkillCapability>("\"telepathy\"").unwrap_err();
201        assert!(err.to_string().contains("unknown MCP capability"));
202    }
203
204    #[test]
205    fn validate_empty_requirements() {
206        assert!(validate_requirements(&[]).is_ok());
207    }
208
209    #[test]
210    fn validate_single_requirement() {
211        let reqs = vec![McpRequirement {
212            tool_pattern: "browser.*".into(),
213            capability: SkillCapability::NetworkHttp,
214            fallback: String::new(),
215        }];
216        assert!(validate_requirements(&reqs).is_ok());
217    }
218
219    #[test]
220    fn validate_rejects_empty_pattern() {
221        let reqs = vec![McpRequirement {
222            tool_pattern: String::new(),
223            capability: SkillCapability::ReadFile,
224            fallback: String::new(),
225        }];
226        let err = validate_requirements(&reqs).unwrap_err();
227        assert_eq!(err.0, 0);
228        assert!(err.1.contains("tool_pattern must not be empty"));
229    }
230
231    #[test]
232    fn validate_rejects_duplicate() {
233        let reqs = vec![
234            McpRequirement {
235                tool_pattern: "browser.*".into(),
236                capability: SkillCapability::NetworkHttp,
237                fallback: String::new(),
238            },
239            McpRequirement {
240                tool_pattern: "browser.*".into(),
241                capability: SkillCapability::NetworkHttp,
242                fallback: String::new(),
243            },
244        ];
245        let err = validate_requirements(&reqs).unwrap_err();
246        assert_eq!(err.0, 1);
247        assert!(err.1.contains("duplicate"));
248    }
249
250    #[test]
251    fn validate_allows_same_pattern_different_capability() {
252        let reqs = vec![
253            McpRequirement {
254                tool_pattern: "fs.*".into(),
255                capability: SkillCapability::ReadFile,
256                fallback: String::new(),
257            },
258            McpRequirement {
259                tool_pattern: "fs.*".into(),
260                capability: SkillCapability::WriteFile,
261                fallback: String::new(),
262            },
263        ];
264        assert!(validate_requirements(&reqs).is_ok());
265    }
266}