use std::collections::HashMap;
use crate::family::FamilyMeta;
use crate::skill::Skill;
#[derive(Debug, Clone)]
pub enum SkillCapability {
Ready,
Unavailable {
reason: String,
hint: Option<String>,
},
}
impl SkillCapability {
pub fn unavailable(reason: impl Into<String>, hint: impl Into<String>) -> Self {
Self::Unavailable {
reason: reason.into(),
hint: Some(hint.into()),
}
}
pub fn unavailable_no_hint(reason: impl Into<String>) -> Self {
Self::Unavailable {
reason: reason.into(),
hint: None,
}
}
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
}
pub fn combine(family: &SkillCapability, skill: &SkillCapability) -> SkillCapability {
match family {
SkillCapability::Unavailable { .. } => family.clone(),
SkillCapability::Ready => skill.clone(),
}
}
#[derive(Debug, Clone, Default)]
pub struct Capabilities {
tools: HashMap<&'static str, SkillCapability>,
families: HashMap<&'static str, SkillCapability>,
}
impl Capabilities {
pub fn resolved(&self, tool: &str) -> SkillCapability {
self.tools
.get(tool)
.cloned()
.unwrap_or(SkillCapability::Ready)
}
pub fn is_ready(&self, tool: &str) -> bool {
self.tools.get(tool).map_or(true, |c| c.is_ready())
}
pub fn tools(&self) -> &HashMap<&'static str, SkillCapability> {
&self.tools
}
pub fn families(&self) -> &HashMap<&'static str, SkillCapability> {
&self.families
}
pub fn unavailable_tools(&self) -> Vec<(&'static str, &SkillCapability)> {
self.tools
.iter()
.filter(|(_, c)| !c.is_ready())
.map(|(n, c)| (*n, c))
.collect()
}
pub fn unavailable_families(&self) -> Vec<(&'static str, &SkillCapability)> {
self.families
.iter()
.filter(|(_, c)| !c.is_ready())
.map(|(n, c)| (*n, c))
.collect()
}
}
pub fn resolve<S: 'static>(
families: &[Box<dyn FamilyMeta>],
skills: &[Box<dyn Skill<S>>],
) -> Capabilities {
let family_caps: HashMap<&'static str, SkillCapability> = families
.iter()
.map(|f| (f.family(), f.check_capability()))
.collect();
let mut tool_to_family: HashMap<&'static str, &'static str> = HashMap::new();
for fam in families {
let name = fam.family();
for t in fam.tools() {
tool_to_family.insert(t, name);
}
}
let mut tools: HashMap<&'static str, SkillCapability> = HashMap::new();
for skill in skills {
let tool_name = skill.name();
let family_cap = tool_to_family
.get(tool_name)
.and_then(|f| family_caps.get(f))
.cloned()
.unwrap_or(SkillCapability::Ready);
tools.insert(tool_name, combine(&family_cap, &skill.check_capability()));
}
Capabilities {
tools,
families: family_caps,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skill::{schema_for, NoArgs, SkillCtx};
use futures::future::BoxFuture;
use rmcp::model::{CallToolResult, JsonObject};
use rmcp::ErrorData as McpError;
use std::sync::Arc;
struct TestSkill {
name: &'static str,
cap: SkillCapability,
}
impl Skill<()> for TestSkill {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"test"
}
fn schema(&self) -> Arc<JsonObject> {
schema_for::<NoArgs>()
}
fn check_capability(&self) -> SkillCapability {
self.cap.clone()
}
fn call<'a>(
&self,
_ctx: SkillCtx<'a, ()>,
) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
Box::pin(async move { Ok(crate::text_result("ok")) })
}
}
struct TestFamily {
name: &'static str,
tools: &'static [&'static str],
cap: SkillCapability,
}
impl FamilyMeta for TestFamily {
fn family(&self) -> &'static str {
self.name
}
fn tools(&self) -> Vec<&'static str> {
self.tools.to_vec()
}
fn description(&self) -> &'static str {
"test family"
}
fn check_capability(&self) -> SkillCapability {
self.cap.clone()
}
}
#[test]
fn ready_is_ready() {
assert!(SkillCapability::Ready.is_ready());
}
#[test]
fn unavailable_carries_reason_and_hint() {
let c = SkillCapability::unavailable("no socket", "mount it");
assert!(!c.is_ready());
match c {
SkillCapability::Unavailable { reason, hint } => {
assert_eq!(reason, "no socket");
assert_eq!(hint.as_deref(), Some("mount it"));
}
_ => panic!(),
}
}
#[test]
fn unavailable_no_hint_omits_hint() {
match SkillCapability::unavailable_no_hint("x86 only") {
SkillCapability::Unavailable { hint, .. } => assert!(hint.is_none()),
_ => panic!(),
}
}
#[test]
fn combine_family_unavailable_wins() {
let fam = SkillCapability::unavailable("no socket", "mount it");
match combine(&fam, &SkillCapability::unavailable_no_hint("other")) {
SkillCapability::Unavailable { reason, hint } => {
assert_eq!(reason, "no socket");
assert_eq!(hint.as_deref(), Some("mount it"));
}
_ => panic!(),
}
}
#[test]
fn combine_skill_applies_when_family_ready() {
let merged = combine(
&SkillCapability::Ready,
&SkillCapability::unavailable_no_hint("x86 only"),
);
assert!(!merged.is_ready());
assert!(combine(&SkillCapability::Ready, &SkillCapability::Ready).is_ready());
}
#[test]
fn resolve_propagates_family_and_tool_probes() {
let families: Vec<Box<dyn FamilyMeta>> = vec![
Box::new(TestFamily {
name: "docker",
tools: &["docker_ps", "docker_run"],
cap: SkillCapability::unavailable("daemon unreachable", "start docker"),
}),
Box::new(TestFamily {
name: "math",
tools: &["add"],
cap: SkillCapability::Ready,
}),
];
let skills: Vec<Box<dyn Skill<()>>> = vec![
Box::new(TestSkill {
name: "docker_ps",
cap: SkillCapability::Ready,
}),
Box::new(TestSkill {
name: "docker_run",
cap: SkillCapability::Ready,
}),
Box::new(TestSkill {
name: "add",
cap: SkillCapability::Ready,
}),
Box::new(TestSkill {
name: "gpu_only",
cap: SkillCapability::unavailable_no_hint("needs an NVIDIA GPU"),
}),
];
let caps = resolve(&families, &skills);
assert!(!caps.is_ready("docker_ps"));
assert!(!caps.is_ready("docker_run"));
match caps.resolved("docker_ps") {
SkillCapability::Unavailable { reason, .. } => {
assert_eq!(reason, "daemon unreachable");
}
_ => panic!("family Unavailable should propagate to its tools"),
}
assert!(caps.is_ready("add"));
assert!(!caps.is_ready("gpu_only"));
assert!(caps.is_ready("not_registered"));
assert_eq!(caps.unavailable_tools().len(), 3);
assert_eq!(caps.unavailable_families().len(), 1);
}
}