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
# mcp-skill-framework

A small framework for building [Model Context Protocol][mcp] 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.

```rust
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:

```json
{
  "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:

```rust
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:

```rust
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.

## Relationship to rmcp (full passthrough)

This is a thin layer on top of [`rmcp`], not a wall around it. `Skill` and
`route_skill` are defined directly in terms of rmcp's own types
(`CallToolResult`, `ErrorData`, `JsonObject`, `ToolRoute`), so you keep full
access to everything rmcp offers — you build the `ToolRouter`, implement
`ServerHandler`, and pick a transport with rmcp, then just `add_route` the routes
this crate hands you:

```rust
use mcp_skill_framework::{rmcp, routes_gated};
use rmcp::handler::server::router::tool::ToolRouter;

let (routes, _caps) = routes_gated(&families(), all_skills());
let mut router: ToolRouter<App> = ToolRouter::new();
for route in routes {
    router.add_route(route);
}
// ... hand `router` to your ServerHandler (rmcp's #[tool_handler]) and serve.
```

Because the public API exposes rmcp types, **rmcp is re-exported** as
`mcp_skill_framework::rmcp` — so you pin one compatible version and don't add
rmcp to your own manifest just to name those types. (To enable an rmcp transport
*feature* — stdio, streamable HTTP — add `rmcp` with that feature to your
`Cargo.toml`; Cargo unifies it with the re-exported version.) Your argument
structs still derive `serde::Deserialize` + `schemars::JsonSchema` as usual, so
add those two; `schemars` is also re-exported for version reference.
`mcp_skill_framework::prelude::*` glob-imports the names a skill body needs.

`route_skill` turns each `Skill` into a `ToolRoute<S>`; the dispatcher applies
the validation (and, via `route_skill_gated`/`routes_gated`, capability) gate
and then calls your body. Other concerns (backgrounding, recall, metrics) are
left for you to wrap around it — see [`dispatch`], and `with_extra_property` for
injecting a global argument (a `background` flag, say) into every tool's schema.

## Examples

```sh
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
cargo run --example server     # a complete MCP server over stdio
```

- [`examples/duration.rs`]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`]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.
- [`examples/server.rs`]examples/server.rs — a **complete, runnable MCP
  server** over stdio: build a `ToolRouter`, implement rmcp's `ServerHandler`
  (`#[tool_handler]`), and serve. Drive it with any MCP client — `initialize`,
  `tools/list`, and `tools/call` (including a live `validation_failed` reply)
  all work through the real protocol.

## 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](LICENSE).

[mcp]: https://modelcontextprotocol.io
[`rmcp`]: https://crates.io/crates/rmcp
[`skill`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/skill/
[`validation`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/validation/
[`capability`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/capability/
[`family`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/family/
[`describe`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/describe/
[`dispatch`]: https://docs.rs/mcp-skill-framework/latest/mcp_skill_framework/dispatch/