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
//! Turning skills into rmcp tool routes.
//!
//! [`route_skill`] adapts one [`Skill`] into an [`rmcp`] [`ToolRoute`] you can
//! add to a `ToolRouter<S>`. The adapter applies the framework's intrinsic
//! pre-call behavior — the **declarative validation gate** — and then invokes
//! the skill body:
//!
//! 1. Build the MCP [`Tool`] metadata (`name`, `description`, `inputSchema`)
//!    from the skill.
//! 2. On each call, run [`Skill::validate`]. On failure, return the structured
//!    `{"validation_failed": [...]}` payload as the result so the calling model
//!    can correct itself without parsing English error strings — the body never
//!    runs.
//! 3. On success, build a [`SkillCtx`] and await the skill body.
//!
//! Application-specific concerns (capability gating, backgrounding, recall,
//! per-tool metrics) are intentionally NOT baked in here — wrap or replace
//! this adapter when you need them. [`with_extra_property`] helps with one
//! common extension: injecting a global argument into every tool's schema.

use std::sync::Arc;

use rmcp::handler::server::router::tool::ToolRoute;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::model::{JsonObject, Tool};
use rmcp::ErrorData as McpError;
use serde_json::Value;

use crate::capability::{resolve, Capabilities, SkillCapability};
use crate::family::FamilyMeta;
use crate::skill::{Skill, SkillCtx};
use crate::text_result;
use crate::validation::ValidationResult;

/// Adapt one boxed [`Skill`] into a ready-to-register [`ToolRoute`]. Applies
/// the declarative validation gate before invoking the skill body. See the
/// [module docs](self) for the exact behavior.
pub fn route_skill<S>(skill: Box<dyn Skill<S>>) -> ToolRoute<S>
where
    S: Send + Sync + 'static,
{
    let tool = Tool::new(
        skill.name().to_string(),
        skill.description().to_string(),
        skill.schema(),
    );
    // Boxed → shared so the `Fn` closure can borrow it across many calls.
    let skill: Arc<dyn Skill<S>> = Arc::from(skill);
    ToolRoute::new_dyn(tool, move |ctx: ToolCallContext<'_, S>| {
        let server = ctx.service;
        let args = ctx.arguments.unwrap_or_default();

        // Declarative-validation gate. On failure, hand back the structured
        // payload as the call result; the body does not run.
        let verdict = skill.validate(&args);
        if let ValidationResult::Fail(_) = verdict {
            let body = serde_json::to_string(&verdict.to_payload()).unwrap_or_default();
            return Box::pin(async move { Ok(text_result(body)) });
        }

        let sctx = SkillCtx {
            server,
            args,
            peer: Some(ctx.request_context.peer.clone()),
            meta: Some(ctx.request_context.meta.clone()),
        };
        // The skill body already returns a `BoxFuture<'a, _>` of the right type.
        skill.call(sctx)
    })
}

/// Like [`route_skill`], but also enforces a **system-requirements gate**.
///
/// Pass the tool's resolved capability (see [`crate::capability::resolve`]).
/// When it is [`SkillCapability::Unavailable`], every call short-circuits with
/// an `invalid_request` error carrying the reason — and the hint, when present
/// — *before* validation or the body runs, so the caller learns exactly what
/// the host is missing and can pick another path. When it is
/// [`SkillCapability::Ready`], this is identical to [`route_skill`].
pub fn route_skill_gated<S>(skill: Box<dyn Skill<S>>, capability: SkillCapability) -> ToolRoute<S>
where
    S: Send + Sync + 'static,
{
    let SkillCapability::Unavailable { reason, hint } = capability else {
        return route_skill(skill);
    };
    let name = skill.name();
    let msg = match hint {
        Some(h) => format!("tool '{name}' is unavailable on this host: {reason}{h}"),
        None => format!("tool '{name}' is unavailable on this host: {reason}"),
    };
    let tool = Tool::new(
        name.to_string(),
        skill.description().to_string(),
        skill.schema(),
    );
    ToolRoute::new_dyn(tool, move |_ctx: ToolCallContext<'_, S>| {
        let err = McpError::invalid_request(msg.clone(), None);
        Box::pin(async move { Err(err) })
    })
}

/// Resolve capabilities for a whole tool set and build a capability-gated
/// [`ToolRoute`] for every skill in one step. Returns the routes alongside the
/// resolved [`Capabilities`] map — keep the map for a startup log line or a
/// status snapshot. Equivalent to [`crate::capability::resolve`] followed by
/// [`route_skill_gated`] per skill.
///
/// ```no_run
/// # use mcp_skill_framework::{routes_gated, FamilyMeta, Skill};
/// # fn demo<S: Send + Sync + 'static>(families: Vec<Box<dyn FamilyMeta>>, skills: Vec<Box<dyn Skill<S>>>) {
/// let (routes, caps) = routes_gated(&families, skills);
/// for (tool, _) in caps.unavailable_tools() {
///     eprintln!("note: tool `{tool}` is blocked on this host");
/// }
/// // add `routes` to your rmcp ToolRouter<S>
/// # let _ = routes;
/// # }
/// ```
pub fn routes_gated<S>(
    families: &[Box<dyn FamilyMeta>],
    skills: Vec<Box<dyn Skill<S>>>,
) -> (Vec<ToolRoute<S>>, Capabilities)
where
    S: Send + Sync + 'static,
{
    let caps = resolve(families, &skills);
    let routes = skills
        .into_iter()
        .map(|s| {
            let cap = caps.resolved(s.name());
            route_skill_gated(s, cap)
        })
        .collect();
    (routes, caps)
}

/// Inject a property into a tool's argument schema, returning a new schema.
///
/// Useful for "global" arguments your dispatcher injects into every tool
/// (a `background` flag, a `dry_run` flag, …): the model sees one merged
/// schema, and your dispatch wrapper strips the global out before building
/// the [`SkillCtx`]. Idempotent for a given `name` — it overwrites that one
/// property and leaves every skill-specific property alone. Creates the
/// `properties` object if the schema doesn't have one.
pub fn with_extra_property(schema: &JsonObject, name: &str, fragment: Value) -> JsonObject {
    let mut out = schema.clone();
    let properties = out
        .entry("properties".to_string())
        .or_insert_with(|| serde_json::json!({}));
    if let Some(props) = properties.as_object_mut() {
        props.insert(name.to_string(), fragment);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::{json, Map};

    fn obj(v: Value) -> JsonObject {
        match v {
            Value::Object(m) => m.into_iter().collect::<Map<_, _>>(),
            _ => panic!("not an object"),
        }
    }

    #[test]
    fn injects_property_preserving_existing() {
        let schema = obj(json!({
            "type": "object",
            "properties": { "x": { "type": "string" } },
            "required": ["x"],
        }));
        let merged = with_extra_property(
            &schema,
            "background",
            json!({ "type": "boolean", "description": "run in the background" }),
        );
        let props = merged
            .get("properties")
            .and_then(|v| v.as_object())
            .unwrap();
        assert!(props.contains_key("x"), "skill property preserved");
        assert_eq!(
            props["background"]["type"], "boolean",
            "global property injected"
        );
        // `required` untouched — the global is optional.
        let req = merged.get("required").and_then(|v| v.as_array()).unwrap();
        assert_eq!(req.len(), 1);
    }

    #[test]
    fn creates_properties_when_absent() {
        let schema = obj(json!({ "type": "object" }));
        let merged = with_extra_property(&schema, "background", json!({ "type": "boolean" }));
        let props = merged
            .get("properties")
            .and_then(|v| v.as_object())
            .unwrap();
        assert!(props.contains_key("background"));
    }
}