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 Arc;
use BoxFuture;
use ;
use ;
use ErrorData as McpError;
use Deserialize;
; // your shared server state
;
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:
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 SkillCapability;
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_gated;
for in caps.unavailable_tools
// 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:
use ;
use ToolRouter;
let = routes_gated;
let mut router: = new;
for route in routes
// ... 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
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— amediafamily whose probe rides onffmpegbeing on$PATH; showsresolve,routes_gated, and the blocked-tool report. The probe is application code, not a framework helper.examples/server.rs— a complete, runnable MCP server over stdio: build aToolRouter, implement rmcp'sServerHandler(#[tool_handler]), and serve. Drive it with any MCP client —initialize,tools/list, andtools/call(including a livevalidation_failedreply) 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.