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 {}