use serde::{Deserialize, Serialize};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
pub enum SkillCapability {
ReadFile,
ListTools,
Search,
WriteFile,
ExecuteSafe,
NetworkHttp,
}
impl SkillCapability {
pub const ALL: &[SkillCapability] = &[
SkillCapability::ReadFile,
SkillCapability::ListTools,
SkillCapability::Search,
SkillCapability::WriteFile,
SkillCapability::ExecuteSafe,
SkillCapability::NetworkHttp,
];
pub fn as_str(self) -> &'static str {
match self {
SkillCapability::ReadFile => "read_file",
SkillCapability::ListTools => "list_tools",
SkillCapability::Search => "search",
SkillCapability::WriteFile => "write_file",
SkillCapability::ExecuteSafe => "execute_safe",
SkillCapability::NetworkHttp => "network_http",
}
}
}
impl std::fmt::Display for SkillCapability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for SkillCapability {
type Err = ParseCapabilityError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"read_file" => SkillCapability::ReadFile,
"list_tools" => SkillCapability::ListTools,
"search" => SkillCapability::Search,
"write_file" => SkillCapability::WriteFile,
"execute_safe" => SkillCapability::ExecuteSafe,
"network_http" => SkillCapability::NetworkHttp,
other => return Err(ParseCapabilityError(other.to_string())),
})
}
}
#[derive(Debug, thiserror::Error)]
#[error(
"unknown MCP capability '{0}' (expected one of: read_file, list_tools, search, \
write_file, execute_safe, network_http)"
)]
pub struct ParseCapabilityError(pub String);
impl Serialize for SkillCapability {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SkillCapability {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
s.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct McpRequirement {
pub tool_pattern: String,
pub capability: SkillCapability,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub fallback: String,
}
pub fn validate_requirements(reqs: &[McpRequirement]) -> Result<(), (usize, String)> {
use std::collections::HashSet;
let mut seen: HashSet<(&str, &str)> = HashSet::new();
for (i, req) in reqs.iter().enumerate() {
if req.tool_pattern.is_empty() {
return Err((i, "tool_pattern must not be empty".into()));
}
if globset::Glob::new(&req.tool_pattern).is_err() {
return Err((i, format!("invalid glob pattern: '{}'", req.tool_pattern)));
}
let cap_str = req.capability.as_str();
if !seen.insert((&req.tool_pattern, cap_str)) {
return Err((
i,
format!(
"duplicate (tool_pattern, capability) pair: '{}'/{}",
req.tool_pattern, req.capability
),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_capabilities() {
assert_eq!(
"read_file".parse::<SkillCapability>().unwrap(),
SkillCapability::ReadFile
);
assert_eq!(
"list_tools".parse::<SkillCapability>().unwrap(),
SkillCapability::ListTools
);
assert_eq!(
"search".parse::<SkillCapability>().unwrap(),
SkillCapability::Search
);
assert_eq!(
"write_file".parse::<SkillCapability>().unwrap(),
SkillCapability::WriteFile
);
assert_eq!(
"execute_safe".parse::<SkillCapability>().unwrap(),
SkillCapability::ExecuteSafe
);
assert_eq!(
"network_http".parse::<SkillCapability>().unwrap(),
SkillCapability::NetworkHttp
);
}
#[test]
fn parse_invalid_capability() {
let err = "telepathy".parse::<SkillCapability>().unwrap_err();
assert!(err.to_string().contains("unknown MCP capability"));
assert!(err.to_string().contains("telepathy"));
}
#[test]
fn capability_display_roundtrips() {
for cap in SkillCapability::ALL {
let s = cap.to_string();
let parsed: SkillCapability = s.parse().unwrap();
assert_eq!(parsed, *cap);
}
}
#[test]
fn capability_serializes_as_string() {
let json = serde_json::to_string(&SkillCapability::ReadFile).unwrap();
assert_eq!(json, "\"read_file\"");
}
#[test]
fn capability_deserializes_from_string() {
let cap: SkillCapability = serde_json::from_str("\"network_http\"").unwrap();
assert_eq!(cap, SkillCapability::NetworkHttp);
}
#[test]
fn capability_rejects_unknown_string() {
let err = serde_json::from_str::<SkillCapability>("\"telepathy\"").unwrap_err();
assert!(err.to_string().contains("unknown MCP capability"));
}
#[test]
fn validate_empty_requirements() {
assert!(validate_requirements(&[]).is_ok());
}
#[test]
fn validate_single_requirement() {
let reqs = vec![McpRequirement {
tool_pattern: "browser.*".into(),
capability: SkillCapability::NetworkHttp,
fallback: String::new(),
}];
assert!(validate_requirements(&reqs).is_ok());
}
#[test]
fn validate_rejects_empty_pattern() {
let reqs = vec![McpRequirement {
tool_pattern: String::new(),
capability: SkillCapability::ReadFile,
fallback: String::new(),
}];
let err = validate_requirements(&reqs).unwrap_err();
assert_eq!(err.0, 0);
assert!(err.1.contains("tool_pattern must not be empty"));
}
#[test]
fn validate_rejects_duplicate() {
let reqs = vec![
McpRequirement {
tool_pattern: "browser.*".into(),
capability: SkillCapability::NetworkHttp,
fallback: String::new(),
},
McpRequirement {
tool_pattern: "browser.*".into(),
capability: SkillCapability::NetworkHttp,
fallback: String::new(),
},
];
let err = validate_requirements(&reqs).unwrap_err();
assert_eq!(err.0, 1);
assert!(err.1.contains("duplicate"));
}
#[test]
fn validate_allows_same_pattern_different_capability() {
let reqs = vec![
McpRequirement {
tool_pattern: "fs.*".into(),
capability: SkillCapability::ReadFile,
fallback: String::new(),
},
McpRequirement {
tool_pattern: "fs.*".into(),
capability: SkillCapability::WriteFile,
fallback: String::new(),
},
];
assert!(validate_requirements(&reqs).is_ok());
}
}