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
//! A single pure-compute skill, end to end.
//!
//! Run with `cargo run --example duration`. Focus: defining a [`Skill`], its
//! declarative validation, rendering its description on demand, and invoking
//! the body the way the dispatcher would. No host requirements — see the
//! `ffmpeg` example for capability gating.

use std::sync::Arc;

// A consumer needs only this crate (plus serde/serde_json for their own arg
// structs). rmcp and BoxFuture come through the prelude / re-exports, pinned to
// a compatible version — no separate `rmcp` dependency to keep in lockstep.
use mcp_skill_framework::describe;
use mcp_skill_framework::prelude::*;
use mcp_skill_framework::rmcp::model::RawContent;
use serde::Deserialize;
use serde_json::json;

/// Shared server state. A real server would hold HTTP clients, DB handles,
/// configuration, etc. Here we need nothing, so it's a unit struct.
struct App;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct FormatArgs {
    /// Total seconds (positive or negative).
    seconds: i64,
    /// Output style: `human` (default, `2h 30m 45s`) or `hms` (`HH:MM:SS`).
    #[serde(default)]
    style: Option<String>,
}

struct DurationFormat;

impl Skill<App> for DurationFormat {
    fn name(&self) -> &'static str {
        "duration_format"
    }
    fn description(&self) -> &'static str {
        "Format a duration given in seconds as `human` (default) or `hms` (`HH:MM:SS`)."
    }
    fn schema(&self) -> Arc<JsonObject> {
        schema_for::<FormatArgs>()
    }
    // The dispatcher enforces this *before* `call` runs; a bad `style` comes
    // back as a structured `validation_failed` payload, not an error string.
    fn validation_rules(&self) -> &'static [Rule] {
        &[Rule::OneOf {
            field: "style",
            values: &["human", "hms"],
        }]
    }
    fn examples(&self) -> &'static [SkillExample] {
        &[
            SkillExample {
                title: "Human",
                args: r#"{"seconds": 9045}"#,
                note: Some("Returns `2h 30m 45s`."),
            },
            SkillExample {
                title: "HH:MM:SS",
                args: r#"{"seconds": 9045, "style": "hms"}"#,
                note: Some("Returns `02:30:45`."),
            },
        ]
    }
    fn use_cases(&self) -> &'static [&'static str] {
        &["Render an interval in a chosen style for display."]
    }
    fn call<'a>(&self, ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
        Box::pin(async move {
            let (_app, a) = ctx.parse::<FormatArgs>()?;
            let style = a.style.as_deref().unwrap_or("human");
            let secs = a.seconds.abs();
            let sign = if a.seconds < 0 { "-" } else { "" };
            let formatted = match style {
                "hms" => format!(
                    "{sign}{:02}:{:02}:{:02}",
                    secs / 3600,
                    (secs / 60) % 60,
                    secs % 60
                ),
                _ => {
                    let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
                    let mut parts = Vec::new();
                    if h > 0 {
                        parts.push(format!("{h}h"));
                    }
                    if m > 0 {
                        parts.push(format!("{m}m"));
                    }
                    if s > 0 || parts.is_empty() {
                        parts.push(format!("{s}s"));
                    }
                    format!("{sign}{}", parts.join(" "))
                }
            };
            Ok(text_result(
                json!({ "seconds": a.seconds, "style": style, "formatted": formatted }).to_string(),
            ))
        })
    }
}

/// Pull the first text block out of a tool result, for printing.
fn result_text(result: &CallToolResult) -> String {
    result
        .content
        .iter()
        .find_map(|c| match &c.raw {
            RawContent::Text(t) => Some(t.text.clone()),
            _ => None,
        })
        .unwrap_or_default()
}

#[tokio::main]
async fn main() {
    let skill = DurationFormat;

    // 1. Validation gate — a bad enum value is rejected with structure.
    let bad: JsonObject = serde_json::from_str(r#"{"seconds": 60, "style": "whisper"}"#).unwrap();
    match skill.validate(&bad) {
        ValidationResult::Fail(v) => println!("rejected: {}", v[0].message),
        ValidationResult::Pass => println!("(unexpectedly passed)"),
    }

    // 2. A valid call — invoke the body directly, the way the dispatcher does
    //    once validation passes. (`peer`/`meta` are only needed by tools that
    //    emit progress notifications, so `None` is fine here.)
    let good: JsonObject = serde_json::from_str(r#"{"seconds": 9045, "style": "hms"}"#).unwrap();
    let ctx = SkillCtx {
        server: &App,
        args: good,
        peer: None,
        meta: None,
    };
    match skill.call(ctx).await {
        Ok(result) => println!("result: {}", result_text(&result)),
        Err(e) => println!("error: {}", e.message),
    }

    // 3. Render the skill's full description on demand (for a `describe_skill`
    //    introspection tool).
    println!(
        "\n{}",
        describe::render_skill(&skill, Some("duration"), None)
    );

    // 4. Adapt the skill into an rmcp tool route, ready for a ToolRouter<App>.
    let _route = route_skill(Box::new(DurationFormat));
    println!("route built for `duration_format`");
}