Skip to main content

mcp_skill_framework/
skill.rs

1//! The skill contract — the uniform capability unit of an MCP server.
2//!
3//! Every tool a server exposes is a **skill**: a self-contained type that
4//! implements [`Skill`] (`name` / `description` / `schema` / `call`, plus
5//! optional metadata). The trait is generic over a server-state type `S`,
6//! which is whatever shared state your tools need — HTTP clients, database
7//! handles, configuration. The framework never looks inside `S`; it only
8//! hands each call a `&S` so the tool body can use it.
9//!
10//! ```no_run
11//! use std::sync::Arc;
12//! use futures::future::BoxFuture;
13//! use mcp_skill_framework::{schema_for, text_result, Skill, SkillCtx};
14//! use rmcp::model::{CallToolResult, JsonObject};
15//! use rmcp::ErrorData as McpError;
16//! use serde::Deserialize;
17//!
18//! struct App; // your shared server state
19//!
20//! #[derive(Deserialize, schemars::JsonSchema)]
21//! struct GreetArgs {
22//!     /// Who to greet.
23//!     name: String,
24//! }
25//!
26//! struct Greet;
27//! impl Skill<App> for Greet {
28//!     fn name(&self) -> &'static str { "greet" }
29//!     fn description(&self) -> &'static str { "Greet someone by name." }
30//!     fn schema(&self) -> Arc<JsonObject> { schema_for::<GreetArgs>() }
31//!     fn call<'a>(&self, ctx: SkillCtx<'a, App>) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
32//!         Box::pin(async move {
33//!             let (_app, args) = ctx.parse::<GreetArgs>()?;
34//!             Ok(text_result(format!("Hello, {}!", args.name)))
35//!         })
36//!     }
37//! }
38//! ```
39
40use std::sync::Arc;
41
42use futures::future::BoxFuture;
43use rmcp::handler::server::tool::{parse_json_object, schema_for_type};
44use rmcp::model::{CallToolResult, JsonObject};
45use rmcp::ErrorData as McpError;
46use schemars::JsonSchema;
47use serde::de::DeserializeOwned;
48
49use crate::capability::SkillCapability;
50use crate::validation::{self, Rule, ValidationResult};
51
52/// What a [`Skill::call`] receives: a borrow of the shared server state plus
53/// the raw, already-extracted argument object (parse it with
54/// [`SkillCtx::parse`]).
55///
56/// `peer` + `meta` mirror the rmcp request context for the underlying tool
57/// call. Skills that emit progress notifications read the caller's
58/// `progressToken` out of `meta` and use `peer` to send them; plain
59/// synchronous tools ignore both. They are owned rather than borrowed
60/// because `Peer` is cheap to clone (transport-channel handles only) and a
61/// skill routinely needs to hand it into a spawned task that outlives the
62/// call.
63pub struct SkillCtx<'a, S> {
64    /// Borrow of the shared server state.
65    pub server: &'a S,
66    /// The raw argument object (already extracted from the request).
67    pub args: JsonObject,
68    /// rmcp peer handle. `None` only in hand-constructed test contexts.
69    pub peer: Option<rmcp::service::Peer<rmcp::RoleServer>>,
70    /// rmcp request `_meta` (the dictionary carrying `progressToken`, etc.).
71    pub meta: Option<rmcp::model::Meta>,
72}
73
74impl<'a, S> SkillCtx<'a, S> {
75    /// Parse the arguments into a typed struct, returning the server handle too.
76    pub fn parse<T: DeserializeOwned>(self) -> Result<(&'a S, T), McpError> {
77        let args = parse_json_object::<T>(self.args)?;
78        Ok((self.server, args))
79    }
80
81    /// Convenience: pull the MCP `progressToken` the caller put in
82    /// `_meta.progressToken`, if any.
83    pub fn progress_token(&self) -> Option<rmcp::model::ProgressToken> {
84        self.meta.as_ref().and_then(|m| m.get_progress_token())
85    }
86}
87
88/// One concrete worked example for a [`Skill`]. Surfaced through an
89/// introspection tool (see [`crate::describe`]); not part of the MCP
90/// `tools/list` payload, which stays tight (just `description` and
91/// `inputSchema`) so the orientation is paid for once and looked up on
92/// demand thereafter.
93pub struct SkillExample {
94    /// One-line summary of what this example demonstrates.
95    pub title: &'static str,
96    /// The tool arguments as a JSON literal, e.g. `r#"{"image": "nginx:1.27"}"#`.
97    /// Kept as a string so each example is embeddable verbatim into an LLM
98    /// context without round-tripping through `serde_json`.
99    pub args: &'static str,
100    /// Optional short note — what the output shape looks like, common
101    /// gotchas, the right next call. Omit when self-explanatory.
102    pub note: Option<&'static str>,
103}
104
105/// The contract every tool implements. Object-safe, so skills are stored as
106/// `Box<dyn Skill<S>>` and assembled uniformly. Generic over the shared
107/// server-state type `S`.
108pub trait Skill<S>: Send + Sync + 'static {
109    /// Tool name (the MCP `name`, e.g. `translate`).
110    fn name(&self) -> &'static str;
111
112    /// One-line tool description shown to the model.
113    fn description(&self) -> &'static str;
114
115    /// JSON schema of the tool's arguments. Build it with [`schema_for`].
116    fn schema(&self) -> Arc<JsonObject>;
117
118    /// Run the tool.
119    fn call<'a>(&self, ctx: SkillCtx<'a, S>) -> BoxFuture<'a, Result<CallToolResult, McpError>>;
120
121    /// Canonical invocation examples. Defaults to empty; opt in to surface
122    /// worked examples through introspection. See [`SkillExample`].
123    fn examples(&self) -> &'static [SkillExample] {
124        &[]
125    }
126
127    /// Short phrases naming the situations this tool is the right answer for.
128    /// Defaults to empty. A model uses these to disambiguate between
129    /// similarly-named tools; the dispatcher does not consult them.
130    fn use_cases(&self) -> &'static [&'static str] {
131        &[]
132    }
133
134    /// Declarative validation rules evaluated by the dispatcher BEFORE the
135    /// call body runs. Defaults to empty (no rules). Override to assert
136    /// domain constraints (range bounds, allowed enum values, mutual
137    /// exclusion) so the caller gets a structured `validation_failed` payload
138    /// it can correct from, rather than a free-form error string. See
139    /// [`crate::validation::Rule`] for the DSL.
140    fn validation_rules(&self) -> &'static [Rule] {
141        &[]
142    }
143
144    /// Run validation against the parsed argument object. The default impl
145    /// evaluates [`Self::validation_rules`] — most skills only override the
146    /// declarative rule list. Override this directly when you need fully
147    /// imperative validation that can't be expressed in the DSL.
148    fn validate(&self, args: &JsonObject) -> ValidationResult {
149        validation::evaluate(self.validation_rules(), args)
150    }
151
152    /// Per-tool capability probe — defaults to [`SkillCapability::Ready`].
153    /// Override when a single tool has a requirement its family doesn't
154    /// cover (a stricter binary, a compile-time feature, a configured
155    /// endpoint). Probes are stateless and run once at startup.
156    fn check_capability(&self) -> SkillCapability {
157        SkillCapability::Ready
158    }
159}
160
161/// Build a JSON schema for an arguments struct (helper for [`Skill::schema`]).
162///
163/// The struct must derive [`schemars::JsonSchema`] (and usually
164/// [`serde::Deserialize`] so [`SkillCtx::parse`] can read it). Per-field
165/// `///` doc comments become the schema's property descriptions, which is
166/// what a model reads to fill the arguments.
167pub fn schema_for<T: JsonSchema + 'static>() -> Arc<JsonObject> {
168    schema_for_type::<T>()
169}
170
171/// Empty argument set, for skills that take no parameters.
172#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
173pub struct NoArgs {}