mcp-skill-framework 0.1.0

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

mcp-skill-framework

A small framework for building Model Context Protocol servers in Rust, on top of rmcp, as a uniform layer of self-contained tools called skills.

You write each tool as a Skill: a type with a name, a description, a JSON-schema'd argument struct, and an async call body. The framework supplies the cross-cutting machinery every tool ends up wanting — declarative input validation, capability probes, family grouping, rich descriptions, and a ready-made dispatcher — so your modules hold domain logic and nothing else.

use std::sync::Arc;
use futures::future::BoxFuture;
use mcp_skill_framework::{schema_for, text_result, Rule, Skill, SkillCtx};
use rmcp::model::{CallToolResult, JsonObject};
use rmcp::ErrorData as McpError;
use serde::Deserialize;

struct App; // your shared server state

#[derive(Deserialize, schemars::JsonSchema)]
struct FormatArgs {
    /// Total seconds to format.
    seconds: i64,
    /// Output style: `human` or `hms`.
    #[serde(default)]
    style: Option<String>,
}

struct Format;
impl Skill<App> for Format {
    fn name(&self) -> &'static str { "duration_format" }
    fn description(&self) -> &'static str { "Format seconds as `human` or `hms`." }
    fn schema(&self) -> Arc<JsonObject> { schema_for::<FormatArgs>() }

    // Declarative validation — the dispatcher enforces this *before* `call`.
    fn validation_rules(&self) -> &'static [Rule] {
        &[Rule::OneOf { field: "style", values: &["human", "hms"] }]
    }

    fn call<'a>(&self, ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
        Box::pin(async move {
            let (_app, a) = ctx.parse::<FormatArgs>()?;
            Ok(text_result(format!("{} seconds", a.seconds)))
        })
    }
}

Why

Building an MCP server means writing a lot of tools, and every tool wants the same supporting cast: argument parsing, input validation that a model can actually correct from, a way to say "this tool needs ffmpeg and the host doesn't have it", and a way to describe itself on demand. Hand-rolling that per tool produces drift and copy-paste. This crate factors it into one contract.

What you get

Piece Module What it does
Skill<S> skill The tool contract: name / description / schema / call, plus optional examples, use_cases, validation_rules, check_capability. Generic over your server state S.
Validation DSL validation Rule::{Range, OneOf, Regex, Length, ExactlyOne, AtLeastOne, All, Any, Not, Custom}. Evaluated before call; failures become a structured {"validation_failed": [...]} payload.
SkillCapability capability Ready / Unavailable { reason, hint } — the result of a host probe you write, plus resolve to combine family + tool probes into a per-tool map.
FamilyMeta family Group related skills, describe them, probe their shared host requirement as a unit.
render_skill / render_family describe Plain-text rendering for an on-demand describe_skill / describe_family introspection tool.
route_skill dispatch Adapt a Skill into an rmcp tool route with the validation gate wired in.

Validation as data

Validation rules are values, not code, so they double as documentation. The dispatcher evaluates them after the arguments arrive and before your body runs. On failure the call returns a structured payload instead of a prose error:

{
  "validation_failed": [
    {
      "field": "style",
      "rule": "one_of",
      "message": "`style` must be one of [\"human\", \"hms\"], got `whisper`",
      "expected": { "one_of": ["human", "hms"] },
      "got": "whisper"
    }
  ]
}

A model reads that and retries correctly without you parsing English error strings. Rules also render through [describe::render_skill], so the same declaration powers both enforcement and self-description.

System requirements: capability gating

Validation answers "are these arguments well-formed?" Capabilities answer a different question — "can this host even run this tool?" — and keep it separate from whether the operator enabled it.

A tool — or a whole family via FamilyMeta — declares a probe. The probe itself is yours to write (the framework defines the contract, not the host checks); on_path below is application code:

use mcp_skill_framework::SkillCapability;

fn check_capability(&self) -> SkillCapability {
    if on_path("ffmpeg") {
        SkillCapability::Ready
    } else {
        SkillCapability::unavailable("`ffmpeg` not found on $PATH", "install ffmpeg")
    }
}

At startup, capability::resolve runs every probe once and combines each tool's own probe with its family's — family Unavailable wins, because the family hint ("install ffmpeg") is usually the actionable one. routes_gated then wires the result into dispatch in one call:

let (routes, caps) = routes_gated(&families, skills);
for (tool, _) in caps.unavailable_tools() {
    eprintln!("note: `{tool}` is unavailable on this host");
}
// add `routes` to your rmcp ToolRouter<S>

A call to a blocked tool is refused at dispatch — before validation or your body runs — with an invalid_request error like tool 'media_probe' is unavailable on this host: \ffmpeg` not found on $PATH — install ffmpeg, so the calling model learns exactly what's missing and can choose another path. The returned Capabilities` map is also handy for a one-line startup log or a status snapshot.

Wiring it into an rmcp server

route_skill turns each Skill into a ToolRoute<S> you add to an rmcp ToolRouter<S>. The framework's dispatcher applies the validation gate and then calls your body; application-specific concerns (capability gating, backgrounding, recall, metrics) are intentionally left for you to wrap around it. See dispatch for the extension points, and with_extra_property for injecting a global argument (a background flag, say) into every tool's schema.

Examples

cargo run --example duration   # a pure skill: validation, description, invoking the body
cargo run --example ffmpeg     # capability gating: a family that needs a host binary
  • examples/duration.rs — one pure-compute skill end to end: declarative validation, on-demand description rendering, and calling the body the way the dispatcher does.
  • examples/ffmpeg.rs — a media family whose probe rides on ffmpeg being on $PATH; shows resolve, routes_gated, and the blocked-tool report. The probe is application code, not a framework helper.

Status

Early (0.1). The skill/validation/capability/family surface is stable in shape; the dispatcher is deliberately minimal and may grow opt-in extensions. Targets rmcp 1.7.

License

MIT © Ely Erin Fox. See LICENSE.