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
//! The skill contract — the uniform capability unit of an MCP server.
//!
//! Every tool a server exposes is a **skill**: a self-contained type that
//! implements [`Skill`] (`name` / `description` / `schema` / `call`, plus
//! optional metadata). The trait is generic over a server-state type `S`,
//! which is whatever shared state your tools need — HTTP clients, database
//! handles, configuration. The framework never looks inside `S`; it only
//! hands each call a `&S` so the tool body can use it.
//!
//! ```no_run
//! use std::sync::Arc;
//! use futures::future::BoxFuture;
//! use mcp_skill_framework::{schema_for, text_result, 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 GreetArgs {
//!     /// Who to greet.
//!     name: String,
//! }
//!
//! struct Greet;
//! impl Skill<App> for Greet {
//!     fn name(&self) -> &'static str { "greet" }
//!     fn description(&self) -> &'static str { "Greet someone by name." }
//!     fn schema(&self) -> Arc<JsonObject> { schema_for::<GreetArgs>() }
//!     fn call<'a>(&self, ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
//!         Box::pin(async move {
//!             let (_app, args) = ctx.parse::<GreetArgs>()?;
//!             Ok(text_result(format!("Hello, {}!", args.name)))
//!         })
//!     }
//! }
//! ```

use std::sync::Arc;

use futures::future::BoxFuture;
use rmcp::handler::server::tool::{parse_json_object, schema_for_type};
use rmcp::model::{CallToolResult, JsonObject};
use rmcp::ErrorData as McpError;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;

use crate::capability::SkillCapability;
use crate::validation::{self, Rule, ValidationResult};

/// What a [`Skill::call`] receives: a borrow of the shared server state plus
/// the raw, already-extracted argument object (parse it with
/// [`SkillCtx::parse`]).
///
/// `peer` + `meta` mirror the rmcp request context for the underlying tool
/// call. Skills that emit progress notifications read the caller's
/// `progressToken` out of `meta` and use `peer` to send them; plain
/// synchronous tools ignore both. They are owned rather than borrowed
/// because `Peer` is cheap to clone (transport-channel handles only) and a
/// skill routinely needs to hand it into a spawned task that outlives the
/// call.
pub struct SkillCtx<'a, S> {
    /// Borrow of the shared server state.
    pub server: &'a S,
    /// The raw argument object (already extracted from the request).
    pub args: JsonObject,
    /// rmcp peer handle. `None` only in hand-constructed test contexts.
    pub peer: Option<rmcp::service::Peer<rmcp::RoleServer>>,
    /// rmcp request `_meta` (the dictionary carrying `progressToken`, etc.).
    pub meta: Option<rmcp::model::Meta>,
}

impl<'a, S> SkillCtx<'a, S> {
    /// Parse the arguments into a typed struct, returning the server handle too.
    pub fn parse<T: DeserializeOwned>(self) -> Result<(&'a S, T), McpError> {
        let args = parse_json_object::<T>(self.args)?;
        Ok((self.server, args))
    }

    /// Convenience: pull the MCP `progressToken` the caller put in
    /// `_meta.progressToken`, if any.
    pub fn progress_token(&self) -> Option<rmcp::model::ProgressToken> {
        self.meta.as_ref().and_then(|m| m.get_progress_token())
    }
}

/// One concrete worked example for a [`Skill`]. Surfaced through an
/// introspection tool (see [`crate::describe`]); not part of the MCP
/// `tools/list` payload, which stays tight (just `description` and
/// `inputSchema`) so the orientation is paid for once and looked up on
/// demand thereafter.
pub struct SkillExample {
    /// One-line summary of what this example demonstrates.
    pub title: &'static str,
    /// The tool arguments as a JSON literal, e.g. `r#"{"image": "nginx:1.27"}"#`.
    /// Kept as a string so each example is embeddable verbatim into an LLM
    /// context without round-tripping through `serde_json`.
    pub args: &'static str,
    /// Optional short note — what the output shape looks like, common
    /// gotchas, the right next call. Omit when self-explanatory.
    pub note: Option<&'static str>,
}

/// The contract every tool implements. Object-safe, so skills are stored as
/// `Box<dyn Skill<S>>` and assembled uniformly. Generic over the shared
/// server-state type `S`.
pub trait Skill<S>: Send + Sync + 'static {
    /// Tool name (the MCP `name`, e.g. `translate`).
    fn name(&self) -> &'static str;

    /// One-line tool description shown to the model.
    fn description(&self) -> &'static str;

    /// JSON schema of the tool's arguments. Build it with [`schema_for`].
    fn schema(&self) -> Arc<JsonObject>;

    /// Run the tool.
    fn call<'a>(&self, ctx: SkillCtx<'a, S>) -> BoxFuture<'a, Result<CallToolResult, McpError>>;

    /// Canonical invocation examples. Defaults to empty; opt in to surface
    /// worked examples through introspection. See [`SkillExample`].
    fn examples(&self) -> &'static [SkillExample] {
        &[]
    }

    /// Short phrases naming the situations this tool is the right answer for.
    /// Defaults to empty. A model uses these to disambiguate between
    /// similarly-named tools; the dispatcher does not consult them.
    fn use_cases(&self) -> &'static [&'static str] {
        &[]
    }

    /// Declarative validation rules evaluated by the dispatcher BEFORE the
    /// call body runs. Defaults to empty (no rules). Override to assert
    /// domain constraints (range bounds, allowed enum values, mutual
    /// exclusion) so the caller gets a structured `validation_failed` payload
    /// it can correct from, rather than a free-form error string. See
    /// [`crate::validation::Rule`] for the DSL.
    fn validation_rules(&self) -> &'static [Rule] {
        &[]
    }

    /// Run validation against the parsed argument object. The default impl
    /// evaluates [`Self::validation_rules`] — most skills only override the
    /// declarative rule list. Override this directly when you need fully
    /// imperative validation that can't be expressed in the DSL.
    fn validate(&self, args: &JsonObject) -> ValidationResult {
        validation::evaluate(self.validation_rules(), args)
    }

    /// Per-tool capability probe — defaults to [`SkillCapability::Ready`].
    /// Override when a single tool has a requirement its family doesn't
    /// cover (a stricter binary, a compile-time feature, a configured
    /// endpoint). Probes are stateless and run once at startup.
    fn check_capability(&self) -> SkillCapability {
        SkillCapability::Ready
    }
}

/// Build a JSON schema for an arguments struct (helper for [`Skill::schema`]).
///
/// The struct must derive [`schemars::JsonSchema`] (and usually
/// [`serde::Deserialize`] so [`SkillCtx::parse`] can read it). Per-field
/// `///` doc comments become the schema's property descriptions, which is
/// what a model reads to fill the arguments.
pub fn schema_for<T: JsonSchema + 'static>() -> Arc<JsonObject> {
    schema_for_type::<T>()
}

/// Empty argument set, for skills that take no parameters.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct NoArgs {}