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}