mcp-skill-framework 0.1.1

A small framework for building MCP (Model Context Protocol) servers as a uniform layer of self-contained tools ("skills"): a typed skill contract, declarative input validation, capability probes, family metadata, and a ready-made dispatcher.
Documentation
//! Capability probes — "can this host actually run this tool?"
//!
//! A skill (or a whole [family](crate::FamilyMeta)) may need something the
//! host doesn't always have: a binary on `$PATH`, a reachable socket, a
//! configured endpoint, a particular CPU architecture. A capability probe
//! answers that question once, at startup, separately from whether the
//! operator *enabled* the tool.
//!
//! The intended flow is three-directional:
//!   1. The dispatcher turns a missing capability into a clean
//!      `invalid_request` error with the reason + a one-line hint — that's
//!      what the calling model sees.
//!   2. Each missing capability is logged once at startup.
//!   3. A status snapshot can carry the per-family state so a dashboard can
//!      render a badge with the reason inline.
//!
//! Probes are stateless: they look at the host (env vars, `$PATH`, file
//! existence, OS) — not at resolved configuration. Anything config-driven
//! is the operator's choice and belongs behind a separate "enabled" flag.
//!
//! ## Resolution
//!
//! A tool inherits its family's probe but may also have its own. [`resolve`]
//! runs every probe once and produces a [`Capabilities`] map keyed by tool
//! name, applying the combination rule in [`combine`]: **family `Unavailable`
//! wins** (its hint is usually the more actionable one); otherwise the tool's
//! own probe applies. Feed each tool's resolved capability into
//! [`route_skill_gated`](crate::dispatch::route_skill_gated) — or use
//! [`routes_gated`](crate::dispatch::routes_gated) — to block unavailable
//! tools at dispatch with a reason + hint the caller can act on.

use std::collections::HashMap;

use crate::family::FamilyMeta;
use crate::skill::Skill;

/// The result of a capability probe.
#[derive(Debug, Clone)]
pub enum SkillCapability {
    /// Probe succeeded — the tool(s) can run.
    Ready,
    /// Probe failed; the tool(s) should be blocked at dispatch. Both strings
    /// are meant to be short enough to render inline.
    Unavailable {
        /// One-line description of what's missing, e.g.
        /// `"Docker daemon socket not reachable"`.
        reason: String,
        /// One-line remediation, e.g.
        /// `"mount /var/run/docker.sock or set DOCKER_HOST"`.
        hint: Option<String>,
    },
}

impl SkillCapability {
    /// Build an `Unavailable` with a remediation hint.
    pub fn unavailable(reason: impl Into<String>, hint: impl Into<String>) -> Self {
        Self::Unavailable {
            reason: reason.into(),
            hint: Some(hint.into()),
        }
    }

    /// Build an `Unavailable` whose failure mode has no actionable hint
    /// (e.g. "x86 disasm only on x86 hosts").
    pub fn unavailable_no_hint(reason: impl Into<String>) -> Self {
        Self::Unavailable {
            reason: reason.into(),
            hint: None,
        }
    }

    /// `true` iff the probe returned [`SkillCapability::Ready`].
    pub fn is_ready(&self) -> bool {
        matches!(self, Self::Ready)
    }
}

/// Combine a family-level probe with a tool-level probe into the effective
/// capability of one tool. **Family `Unavailable` wins** — the family hint is
/// usually the actionable one (e.g. "install ffmpeg"); a tool's own
/// `Unavailable` only applies when its family is `Ready`.
pub fn combine(family: &SkillCapability, skill: &SkillCapability) -> SkillCapability {
    match family {
        SkillCapability::Unavailable { .. } => family.clone(),
        SkillCapability::Ready => skill.clone(),
    }
}

/// Effective capability of every tool on this host, plus the raw per-family
/// probe results. Built by [`resolve`] once at startup; consulted at dispatch
/// (see [`route_skill_gated`](crate::dispatch::route_skill_gated)) and useful
/// for a startup log line or a status snapshot.
#[derive(Debug, Clone, Default)]
pub struct Capabilities {
    tools: HashMap<&'static str, SkillCapability>,
    families: HashMap<&'static str, SkillCapability>,
}

impl Capabilities {
    /// Effective capability for one tool. An unknown tool resolves to
    /// [`SkillCapability::Ready`] (no probe means no requirement).
    pub fn resolved(&self, tool: &str) -> SkillCapability {
        self.tools
            .get(tool)
            .cloned()
            .unwrap_or(SkillCapability::Ready)
    }

    /// `true` iff the tool can run on this host. Unknown tool → `true`.
    pub fn is_ready(&self, tool: &str) -> bool {
        self.tools.get(tool).map_or(true, |c| c.is_ready())
    }

    /// The full effective per-tool map.
    pub fn tools(&self) -> &HashMap<&'static str, SkillCapability> {
        &self.tools
    }

    /// The raw per-family probe map (before the per-tool combination).
    pub fn families(&self) -> &HashMap<&'static str, SkillCapability> {
        &self.families
    }

    /// Tools that are blocked on this host — convenient for emitting one
    /// startup warning per missing requirement.
    pub fn unavailable_tools(&self) -> Vec<(&'static str, &SkillCapability)> {
        self.tools
            .iter()
            .filter(|(_, c)| !c.is_ready())
            .map(|(n, c)| (*n, c))
            .collect()
    }

    /// Families that are blocked on this host.
    pub fn unavailable_families(&self) -> Vec<(&'static str, &SkillCapability)> {
        self.families
            .iter()
            .filter(|(_, c)| !c.is_ready())
            .map(|(n, c)| (*n, c))
            .collect()
    }
}

/// Run every family and skill probe once and resolve the effective capability
/// of each tool. For each tool: start from its family's probe (or `Ready` if
/// the tool belongs to no registered family), then [`combine`] it with the
/// tool's own probe. Tool→family membership is read from each family's
/// [`FamilyMeta::tools`].
pub fn resolve<S: 'static>(
    families: &[Box<dyn FamilyMeta>],
    skills: &[Box<dyn Skill<S>>],
) -> Capabilities {
    // One probe per family, reused for both the membership lookup and the
    // returned per-family map.
    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");
        // Family Unavailable wins even when the skill is also Unavailable, and
        // it's the family's reason/hint that survives.
        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());
        // Ready + Ready stays 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,
            }),
            // No family — the tool's own probe is the only gate.
            Box::new(TestSkill {
                name: "gpu_only",
                cap: SkillCapability::unavailable_no_hint("needs an NVIDIA GPU"),
            }),
        ];
        let caps = resolve(&families, &skills);

        // An Unavailable family blocks all of its tools, even Ready ones, and
        // it's the family's reason that propagates to each tool.
        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"),
        }
        // Ready family + Ready tool.
        assert!(caps.is_ready("add"));
        // No family — the tool's own probe blocks it.
        assert!(!caps.is_ready("gpu_only"));
        // Unknown tool defaults to Ready.
        assert!(caps.is_ready("not_registered"));

        assert_eq!(caps.unavailable_tools().len(), 3);
        assert_eq!(caps.unavailable_families().len(), 1);
    }
}