Skip to main content

mcp_skill_framework/
capability.rs

1//! Capability probes — "can this host actually run this tool?"
2//!
3//! A skill (or a whole [family](crate::FamilyMeta)) may need something the
4//! host doesn't always have: a binary on `$PATH`, a reachable socket, a
5//! configured endpoint, a particular CPU architecture. A capability probe
6//! answers that question once, at startup, separately from whether the
7//! operator *enabled* the tool.
8//!
9//! The intended flow is three-directional:
10//!   1. The dispatcher turns a missing capability into a clean
11//!      `invalid_request` error with the reason + a one-line hint — that's
12//!      what the calling model sees.
13//!   2. Each missing capability is logged once at startup.
14//!   3. A status snapshot can carry the per-family state so a dashboard can
15//!      render a badge with the reason inline.
16//!
17//! Probes are stateless: they look at the host (env vars, `$PATH`, file
18//! existence, OS) — not at resolved configuration. Anything config-driven
19//! is the operator's choice and belongs behind a separate "enabled" flag.
20//!
21//! ## Resolution
22//!
23//! A tool inherits its family's probe but may also have its own. [`resolve`]
24//! runs every probe once and produces a [`Capabilities`] map keyed by tool
25//! name, applying the combination rule in [`combine`]: **family `Unavailable`
26//! wins** (its hint is usually the more actionable one); otherwise the tool's
27//! own probe applies. Feed each tool's resolved capability into
28//! [`route_skill_gated`](crate::dispatch::route_skill_gated) — or use
29//! [`routes_gated`](crate::dispatch::routes_gated) — to block unavailable
30//! tools at dispatch with a reason + hint the caller can act on.
31
32use std::collections::HashMap;
33
34use crate::family::FamilyMeta;
35use crate::skill::Skill;
36
37/// The result of a capability probe.
38#[derive(Debug, Clone)]
39pub enum SkillCapability {
40    /// Probe succeeded — the tool(s) can run.
41    Ready,
42    /// Probe failed; the tool(s) should be blocked at dispatch. Both strings
43    /// are meant to be short enough to render inline.
44    Unavailable {
45        /// One-line description of what's missing, e.g.
46        /// `"Docker daemon socket not reachable"`.
47        reason: String,
48        /// One-line remediation, e.g.
49        /// `"mount /var/run/docker.sock or set DOCKER_HOST"`.
50        hint: Option<String>,
51    },
52}
53
54impl SkillCapability {
55    /// Build an `Unavailable` with a remediation hint.
56    pub fn unavailable(reason: impl Into<String>, hint: impl Into<String>) -> Self {
57        Self::Unavailable {
58            reason: reason.into(),
59            hint: Some(hint.into()),
60        }
61    }
62
63    /// Build an `Unavailable` whose failure mode has no actionable hint
64    /// (e.g. "x86 disasm only on x86 hosts").
65    pub fn unavailable_no_hint(reason: impl Into<String>) -> Self {
66        Self::Unavailable {
67            reason: reason.into(),
68            hint: None,
69        }
70    }
71
72    /// `true` iff the probe returned [`SkillCapability::Ready`].
73    pub fn is_ready(&self) -> bool {
74        matches!(self, Self::Ready)
75    }
76}
77
78/// Combine a family-level probe with a tool-level probe into the effective
79/// capability of one tool. **Family `Unavailable` wins** — the family hint is
80/// usually the actionable one (e.g. "install ffmpeg"); a tool's own
81/// `Unavailable` only applies when its family is `Ready`.
82pub fn combine(family: &SkillCapability, skill: &SkillCapability) -> SkillCapability {
83    match family {
84        SkillCapability::Unavailable { .. } => family.clone(),
85        SkillCapability::Ready => skill.clone(),
86    }
87}
88
89/// Effective capability of every tool on this host, plus the raw per-family
90/// probe results. Built by [`resolve`] once at startup; consulted at dispatch
91/// (see [`route_skill_gated`](crate::dispatch::route_skill_gated)) and useful
92/// for a startup log line or a status snapshot.
93#[derive(Debug, Clone, Default)]
94pub struct Capabilities {
95    tools: HashMap<&'static str, SkillCapability>,
96    families: HashMap<&'static str, SkillCapability>,
97}
98
99impl Capabilities {
100    /// Effective capability for one tool. An unknown tool resolves to
101    /// [`SkillCapability::Ready`] (no probe means no requirement).
102    pub fn resolved(&self, tool: &str) -> SkillCapability {
103        self.tools
104            .get(tool)
105            .cloned()
106            .unwrap_or(SkillCapability::Ready)
107    }
108
109    /// `true` iff the tool can run on this host. Unknown tool → `true`.
110    pub fn is_ready(&self, tool: &str) -> bool {
111        self.tools.get(tool).map_or(true, |c| c.is_ready())
112    }
113
114    /// The full effective per-tool map.
115    pub fn tools(&self) -> &HashMap<&'static str, SkillCapability> {
116        &self.tools
117    }
118
119    /// The raw per-family probe map (before the per-tool combination).
120    pub fn families(&self) -> &HashMap<&'static str, SkillCapability> {
121        &self.families
122    }
123
124    /// Tools that are blocked on this host — convenient for emitting one
125    /// startup warning per missing requirement.
126    pub fn unavailable_tools(&self) -> Vec<(&'static str, &SkillCapability)> {
127        self.tools
128            .iter()
129            .filter(|(_, c)| !c.is_ready())
130            .map(|(n, c)| (*n, c))
131            .collect()
132    }
133
134    /// Families that are blocked on this host.
135    pub fn unavailable_families(&self) -> Vec<(&'static str, &SkillCapability)> {
136        self.families
137            .iter()
138            .filter(|(_, c)| !c.is_ready())
139            .map(|(n, c)| (*n, c))
140            .collect()
141    }
142}
143
144/// Run every family and skill probe once and resolve the effective capability
145/// of each tool. For each tool: start from its family's probe (or `Ready` if
146/// the tool belongs to no registered family), then [`combine`] it with the
147/// tool's own probe. Tool→family membership is read from each family's
148/// [`FamilyMeta::tools`].
149pub fn resolve<S: 'static>(
150    families: &[Box<dyn FamilyMeta>],
151    skills: &[Box<dyn Skill<S>>],
152) -> Capabilities {
153    // One probe per family, reused for both the membership lookup and the
154    // returned per-family map.
155    let family_caps: HashMap<&'static str, SkillCapability> = families
156        .iter()
157        .map(|f| (f.family(), f.check_capability()))
158        .collect();
159
160    let mut tool_to_family: HashMap<&'static str, &'static str> = HashMap::new();
161    for fam in families {
162        let name = fam.family();
163        for t in fam.tools() {
164            tool_to_family.insert(t, name);
165        }
166    }
167
168    let mut tools: HashMap<&'static str, SkillCapability> = HashMap::new();
169    for skill in skills {
170        let tool_name = skill.name();
171        let family_cap = tool_to_family
172            .get(tool_name)
173            .and_then(|f| family_caps.get(f))
174            .cloned()
175            .unwrap_or(SkillCapability::Ready);
176        tools.insert(tool_name, combine(&family_cap, &skill.check_capability()));
177    }
178
179    Capabilities {
180        tools,
181        families: family_caps,
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::skill::{schema_for, NoArgs, SkillCtx};
189    use futures::future::BoxFuture;
190    use rmcp::model::{CallToolResult, JsonObject};
191    use rmcp::ErrorData as McpError;
192    use std::sync::Arc;
193
194    struct TestSkill {
195        name: &'static str,
196        cap: SkillCapability,
197    }
198    impl Skill<()> for TestSkill {
199        fn name(&self) -> &'static str {
200            self.name
201        }
202        fn description(&self) -> &'static str {
203            "test"
204        }
205        fn schema(&self) -> Arc<JsonObject> {
206            schema_for::<NoArgs>()
207        }
208        fn check_capability(&self) -> SkillCapability {
209            self.cap.clone()
210        }
211        fn call<'a>(
212            &self,
213            _ctx: SkillCtx<'a, ()>,
214        ) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
215            Box::pin(async move { Ok(crate::text_result("ok")) })
216        }
217    }
218
219    struct TestFamily {
220        name: &'static str,
221        tools: &'static [&'static str],
222        cap: SkillCapability,
223    }
224    impl FamilyMeta for TestFamily {
225        fn family(&self) -> &'static str {
226            self.name
227        }
228        fn tools(&self) -> Vec<&'static str> {
229            self.tools.to_vec()
230        }
231        fn description(&self) -> &'static str {
232            "test family"
233        }
234        fn check_capability(&self) -> SkillCapability {
235            self.cap.clone()
236        }
237    }
238
239    #[test]
240    fn ready_is_ready() {
241        assert!(SkillCapability::Ready.is_ready());
242    }
243
244    #[test]
245    fn unavailable_carries_reason_and_hint() {
246        let c = SkillCapability::unavailable("no socket", "mount it");
247        assert!(!c.is_ready());
248        match c {
249            SkillCapability::Unavailable { reason, hint } => {
250                assert_eq!(reason, "no socket");
251                assert_eq!(hint.as_deref(), Some("mount it"));
252            }
253            _ => panic!(),
254        }
255    }
256
257    #[test]
258    fn unavailable_no_hint_omits_hint() {
259        match SkillCapability::unavailable_no_hint("x86 only") {
260            SkillCapability::Unavailable { hint, .. } => assert!(hint.is_none()),
261            _ => panic!(),
262        }
263    }
264
265    #[test]
266    fn combine_family_unavailable_wins() {
267        let fam = SkillCapability::unavailable("no socket", "mount it");
268        // Family Unavailable wins even when the skill is also Unavailable, and
269        // it's the family's reason/hint that survives.
270        match combine(&fam, &SkillCapability::unavailable_no_hint("other")) {
271            SkillCapability::Unavailable { reason, hint } => {
272                assert_eq!(reason, "no socket");
273                assert_eq!(hint.as_deref(), Some("mount it"));
274            }
275            _ => panic!(),
276        }
277    }
278
279    #[test]
280    fn combine_skill_applies_when_family_ready() {
281        let merged = combine(
282            &SkillCapability::Ready,
283            &SkillCapability::unavailable_no_hint("x86 only"),
284        );
285        assert!(!merged.is_ready());
286        // Ready + Ready stays Ready.
287        assert!(combine(&SkillCapability::Ready, &SkillCapability::Ready).is_ready());
288    }
289
290    #[test]
291    fn resolve_propagates_family_and_tool_probes() {
292        let families: Vec<Box<dyn FamilyMeta>> = vec![
293            Box::new(TestFamily {
294                name: "docker",
295                tools: &["docker_ps", "docker_run"],
296                cap: SkillCapability::unavailable("daemon unreachable", "start docker"),
297            }),
298            Box::new(TestFamily {
299                name: "math",
300                tools: &["add"],
301                cap: SkillCapability::Ready,
302            }),
303        ];
304        let skills: Vec<Box<dyn Skill<()>>> = vec![
305            Box::new(TestSkill {
306                name: "docker_ps",
307                cap: SkillCapability::Ready,
308            }),
309            Box::new(TestSkill {
310                name: "docker_run",
311                cap: SkillCapability::Ready,
312            }),
313            Box::new(TestSkill {
314                name: "add",
315                cap: SkillCapability::Ready,
316            }),
317            // No family — the tool's own probe is the only gate.
318            Box::new(TestSkill {
319                name: "gpu_only",
320                cap: SkillCapability::unavailable_no_hint("needs an NVIDIA GPU"),
321            }),
322        ];
323        let caps = resolve(&families, &skills);
324
325        // An Unavailable family blocks all of its tools, even Ready ones, and
326        // it's the family's reason that propagates to each tool.
327        assert!(!caps.is_ready("docker_ps"));
328        assert!(!caps.is_ready("docker_run"));
329        match caps.resolved("docker_ps") {
330            SkillCapability::Unavailable { reason, .. } => {
331                assert_eq!(reason, "daemon unreachable");
332            }
333            _ => panic!("family Unavailable should propagate to its tools"),
334        }
335        // Ready family + Ready tool.
336        assert!(caps.is_ready("add"));
337        // No family — the tool's own probe blocks it.
338        assert!(!caps.is_ready("gpu_only"));
339        // Unknown tool defaults to Ready.
340        assert!(caps.is_ready("not_registered"));
341
342        assert_eq!(caps.unavailable_tools().len(), 3);
343        assert_eq!(caps.unavailable_families().len(), 1);
344    }
345}