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
//! System-requirements gating: a family that needs a host binary.
//!
//! Run with `cargo run --example ffmpeg`. Focus: a [`FamilyMeta`] whose
//! capability probe rides on whether `ffmpeg` is on `$PATH`, resolving that
//! verdict across the family's tools, and building capability-gated routes so a
//! call to a tool the host can't run is refused at dispatch with a reason +
//! hint.
//!
//! Note the probe (`on_path`) lives *here*, in the application — the framework
//! defines the capability contract and the resolution/gate, not the probes
//! themselves.

use std::sync::Arc;

use futures::future::BoxFuture;
use mcp_skill_framework::{
    describe, routes_gated, schema_for, text_result, FamilyMeta, NoArgs, Skill, SkillCapability,
    SkillCtx,
};
use rmcp::model::{CallToolResult, JsonObject};
use rmcp::ErrorData as McpError;

/// Shared server state — unused here, so a unit struct.
struct App;

/// A tiny cross-platform `$PATH` search. This is *your* host probe; the
/// framework intentionally ships no such wrapper.
fn on_path(bin: &str) -> bool {
    let Some(paths) = std::env::var_os("PATH") else {
        return false;
    };
    let candidates: Vec<String> = if cfg!(windows) {
        vec![format!("{bin}.exe"), format!("{bin}.cmd"), bin.to_string()]
    } else {
        vec![bin.to_string()]
    };
    std::env::split_paths(&paths).any(|dir| candidates.iter().any(|name| dir.join(name).is_file()))
}

// ---- two tools that both need ffmpeg ---------------------------------------

struct MediaProbe;
impl Skill<App> for MediaProbe {
    fn name(&self) -> &'static str {
        "media_probe"
    }
    fn description(&self) -> &'static str {
        "Probe a media file's streams (would shell out to ffmpeg)."
    }
    fn schema(&self) -> Arc<JsonObject> {
        schema_for::<NoArgs>()
    }
    fn call<'a>(&self, _ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
        Box::pin(async move { Ok(text_result("(would run `ffmpeg -i ...`)")) })
    }
}

struct MediaThumbnail;
impl Skill<App> for MediaThumbnail {
    fn name(&self) -> &'static str {
        "media_thumbnail"
    }
    fn description(&self) -> &'static str {
        "Extract a thumbnail frame (would shell out to ffmpeg)."
    }
    fn schema(&self) -> Arc<JsonObject> {
        schema_for::<NoArgs>()
    }
    fn call<'a>(&self, _ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
        Box::pin(async move { Ok(text_result("(would run `ffmpeg -ss ... -vframes 1`)")) })
    }
}

// ---- the family: one probe gates both tools --------------------------------

struct MediaFamily;
impl FamilyMeta for MediaFamily {
    fn family(&self) -> &'static str {
        "media"
    }
    fn tools(&self) -> Vec<&'static str> {
        vec!["media_probe", "media_thumbnail"]
    }
    fn description(&self) -> &'static str {
        "Media inspection. Requires `ffmpeg` on the host's $PATH."
    }
    fn check_capability(&self) -> SkillCapability {
        // The whole family rides on one host requirement. `resolve` propagates
        // this single verdict to every tool the family lists in `tools()`.
        if on_path("ffmpeg") {
            SkillCapability::Ready
        } else {
            SkillCapability::unavailable("`ffmpeg` not found on $PATH", "install ffmpeg")
        }
    }
    fn example_flow(&self) -> Option<&'static str> {
        Some("1. `media_probe { ... }` then 2. `media_thumbnail { ... }`.")
    }
}

fn families() -> Vec<Box<dyn FamilyMeta>> {
    vec![Box::new(MediaFamily)]
}

fn all_skills() -> Vec<Box<dyn Skill<App>>> {
    vec![Box::new(MediaProbe), Box::new(MediaThumbnail)]
}

fn main() {
    println!("ffmpeg on $PATH: {}\n", on_path("ffmpeg"));

    // Resolve host capabilities and build capability-gated routes in one step.
    // A blocked tool will reject every call at dispatch (before validation or
    // the body) with `invalid_request` carrying the reason + hint.
    let (routes, caps) = routes_gated(&families(), all_skills());
    println!("built {} tool route(s) for App", routes.len());

    let blocked = caps.unavailable_tools();
    if blocked.is_empty() {
        println!("all tools are runnable on this host");
    } else {
        println!("{} tool(s) blocked on this host:", blocked.len());
        for (tool, cap) in blocked {
            if let SkillCapability::Unavailable { reason, hint } = cap {
                match hint {
                    Some(h) => println!("  - {tool}: {reason} ({h})"),
                    None => println!("  - {tool}: {reason}"),
                }
            }
        }
    }

    // Describe the family — render shows its live capability state.
    println!("\n{}", describe::render_family(&MediaFamily, None));
}