Skip to main content

mcp_skill_framework/
dispatch.rs

1//! Turning skills into rmcp tool routes.
2//!
3//! [`route_skill`] adapts one [`Skill`] into an [`rmcp`] [`ToolRoute`] you can
4//! add to a `ToolRouter<S>`. The adapter applies the framework's intrinsic
5//! pre-call behavior — the **declarative validation gate** — and then invokes
6//! the skill body:
7//!
8//! 1. Build the MCP [`Tool`] metadata (`name`, `description`, `inputSchema`)
9//!    from the skill.
10//! 2. On each call, run [`Skill::validate`]. On failure, return the structured
11//!    `{"validation_failed": [...]}` payload as the result so the calling model
12//!    can correct itself without parsing English error strings — the body never
13//!    runs.
14//! 3. On success, build a [`SkillCtx`] and await the skill body.
15//!
16//! Application-specific concerns (capability gating, backgrounding, recall,
17//! per-tool metrics) are intentionally NOT baked in here — wrap or replace
18//! this adapter when you need them. [`with_extra_property`] helps with one
19//! common extension: injecting a global argument into every tool's schema.
20
21use std::sync::Arc;
22
23use rmcp::handler::server::router::tool::ToolRoute;
24use rmcp::handler::server::tool::ToolCallContext;
25use rmcp::model::{JsonObject, Tool};
26use rmcp::ErrorData as McpError;
27use serde_json::Value;
28
29use crate::capability::{resolve, Capabilities, SkillCapability};
30use crate::family::FamilyMeta;
31use crate::skill::{Skill, SkillCtx};
32use crate::text_result;
33use crate::validation::ValidationResult;
34
35/// Adapt one boxed [`Skill`] into a ready-to-register [`ToolRoute`]. Applies
36/// the declarative validation gate before invoking the skill body. See the
37/// [module docs](self) for the exact behavior.
38pub fn route_skill<S>(skill: Box<dyn Skill<S>>) -> ToolRoute<S>
39where
40    S: Send + Sync + 'static,
41{
42    let tool = Tool::new(
43        skill.name().to_string(),
44        skill.description().to_string(),
45        skill.schema(),
46    );
47    // Boxed → shared so the `Fn` closure can borrow it across many calls.
48    let skill: Arc<dyn Skill<S>> = Arc::from(skill);
49    ToolRoute::new_dyn(tool, move |ctx: ToolCallContext<'_, S>| {
50        let server = ctx.service;
51        let args = ctx.arguments.unwrap_or_default();
52
53        // Declarative-validation gate. On failure, hand back the structured
54        // payload as the call result; the body does not run.
55        let verdict = skill.validate(&args);
56        if let ValidationResult::Fail(_) = verdict {
57            let body = serde_json::to_string(&verdict.to_payload()).unwrap_or_default();
58            return Box::pin(async move { Ok(text_result(body)) });
59        }
60
61        let sctx = SkillCtx {
62            server,
63            args,
64            peer: Some(ctx.request_context.peer.clone()),
65            meta: Some(ctx.request_context.meta.clone()),
66        };
67        // The skill body already returns a `BoxFuture<'a, _>` of the right type.
68        skill.call(sctx)
69    })
70}
71
72/// Like [`route_skill`], but also enforces a **system-requirements gate**.
73///
74/// Pass the tool's resolved capability (see [`crate::capability::resolve`]).
75/// When it is [`SkillCapability::Unavailable`], every call short-circuits with
76/// an `invalid_request` error carrying the reason — and the hint, when present
77/// — *before* validation or the body runs, so the caller learns exactly what
78/// the host is missing and can pick another path. When it is
79/// [`SkillCapability::Ready`], this is identical to [`route_skill`].
80pub fn route_skill_gated<S>(skill: Box<dyn Skill<S>>, capability: SkillCapability) -> ToolRoute<S>
81where
82    S: Send + Sync + 'static,
83{
84    let SkillCapability::Unavailable { reason, hint } = capability else {
85        return route_skill(skill);
86    };
87    let name = skill.name();
88    let msg = match hint {
89        Some(h) => format!("tool '{name}' is unavailable on this host: {reason} — {h}"),
90        None => format!("tool '{name}' is unavailable on this host: {reason}"),
91    };
92    let tool = Tool::new(
93        name.to_string(),
94        skill.description().to_string(),
95        skill.schema(),
96    );
97    ToolRoute::new_dyn(tool, move |_ctx: ToolCallContext<'_, S>| {
98        let err = McpError::invalid_request(msg.clone(), None);
99        Box::pin(async move { Err(err) })
100    })
101}
102
103/// Resolve capabilities for a whole tool set and build a capability-gated
104/// [`ToolRoute`] for every skill in one step. Returns the routes alongside the
105/// resolved [`Capabilities`] map — keep the map for a startup log line or a
106/// status snapshot. Equivalent to [`crate::capability::resolve`] followed by
107/// [`route_skill_gated`] per skill.
108///
109/// ```no_run
110/// # use mcp_skill_framework::{routes_gated, FamilyMeta, Skill};
111/// # fn demo<S: Send + Sync + 'static>(families: Vec<Box<dyn FamilyMeta>>, skills: Vec<Box<dyn Skill<S>>>) {
112/// let (routes, caps) = routes_gated(&families, skills);
113/// for (tool, _) in caps.unavailable_tools() {
114///     eprintln!("note: tool `{tool}` is blocked on this host");
115/// }
116/// // add `routes` to your rmcp ToolRouter<S>
117/// # let _ = routes;
118/// # }
119/// ```
120pub fn routes_gated<S>(
121    families: &[Box<dyn FamilyMeta>],
122    skills: Vec<Box<dyn Skill<S>>>,
123) -> (Vec<ToolRoute<S>>, Capabilities)
124where
125    S: Send + Sync + 'static,
126{
127    let caps = resolve(families, &skills);
128    let routes = skills
129        .into_iter()
130        .map(|s| {
131            let cap = caps.resolved(s.name());
132            route_skill_gated(s, cap)
133        })
134        .collect();
135    (routes, caps)
136}
137
138/// Inject a property into a tool's argument schema, returning a new schema.
139///
140/// Useful for "global" arguments your dispatcher injects into every tool
141/// (a `background` flag, a `dry_run` flag, …): the model sees one merged
142/// schema, and your dispatch wrapper strips the global out before building
143/// the [`SkillCtx`]. Idempotent for a given `name` — it overwrites that one
144/// property and leaves every skill-specific property alone. Creates the
145/// `properties` object if the schema doesn't have one.
146pub fn with_extra_property(schema: &JsonObject, name: &str, fragment: Value) -> JsonObject {
147    let mut out = schema.clone();
148    let properties = out
149        .entry("properties".to_string())
150        .or_insert_with(|| serde_json::json!({}));
151    if let Some(props) = properties.as_object_mut() {
152        props.insert(name.to_string(), fragment);
153    }
154    out
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::{json, Map};
161
162    fn obj(v: Value) -> JsonObject {
163        match v {
164            Value::Object(m) => m.into_iter().collect::<Map<_, _>>(),
165            _ => panic!("not an object"),
166        }
167    }
168
169    #[test]
170    fn injects_property_preserving_existing() {
171        let schema = obj(json!({
172            "type": "object",
173            "properties": { "x": { "type": "string" } },
174            "required": ["x"],
175        }));
176        let merged = with_extra_property(
177            &schema,
178            "background",
179            json!({ "type": "boolean", "description": "run in the background" }),
180        );
181        let props = merged
182            .get("properties")
183            .and_then(|v| v.as_object())
184            .unwrap();
185        assert!(props.contains_key("x"), "skill property preserved");
186        assert_eq!(
187            props["background"]["type"], "boolean",
188            "global property injected"
189        );
190        // `required` untouched — the global is optional.
191        let req = merged.get("required").and_then(|v| v.as_array()).unwrap();
192        assert_eq!(req.len(), 1);
193    }
194
195    #[test]
196    fn creates_properties_when_absent() {
197        let schema = obj(json!({ "type": "object" }));
198        let merged = with_extra_property(&schema, "background", json!({ "type": "boolean" }));
199        let props = merged
200            .get("properties")
201            .and_then(|v| v.as_object())
202            .unwrap();
203        assert!(props.contains_key("background"));
204    }
205}