use std::sync::Arc;
use rmcp::handler::server::router::tool::ToolRoute;
use rmcp::handler::server::tool::ToolCallContext;
use rmcp::model::{JsonObject, Tool};
use rmcp::ErrorData as McpError;
use serde_json::Value;
use crate::capability::{resolve, Capabilities, SkillCapability};
use crate::family::FamilyMeta;
use crate::skill::{Skill, SkillCtx};
use crate::text_result;
use crate::validation::ValidationResult;
pub fn route_skill<S>(skill: Box<dyn Skill<S>>) -> ToolRoute<S>
where
S: Send + Sync + 'static,
{
let tool = Tool::new(
skill.name().to_string(),
skill.description().to_string(),
skill.schema(),
);
let skill: Arc<dyn Skill<S>> = Arc::from(skill);
ToolRoute::new_dyn(tool, move |ctx: ToolCallContext<'_, S>| {
let server = ctx.service;
let args = ctx.arguments.unwrap_or_default();
let verdict = skill.validate(&args);
if let ValidationResult::Fail(_) = verdict {
let body = serde_json::to_string(&verdict.to_payload()).unwrap_or_default();
return Box::pin(async move { Ok(text_result(body)) });
}
let sctx = SkillCtx {
server,
args,
peer: Some(ctx.request_context.peer.clone()),
meta: Some(ctx.request_context.meta.clone()),
};
skill.call(sctx)
})
}
pub fn route_skill_gated<S>(skill: Box<dyn Skill<S>>, capability: SkillCapability) -> ToolRoute<S>
where
S: Send + Sync + 'static,
{
let SkillCapability::Unavailable { reason, hint } = capability else {
return route_skill(skill);
};
let name = skill.name();
let msg = match hint {
Some(h) => format!("tool '{name}' is unavailable on this host: {reason} — {h}"),
None => format!("tool '{name}' is unavailable on this host: {reason}"),
};
let tool = Tool::new(
name.to_string(),
skill.description().to_string(),
skill.schema(),
);
ToolRoute::new_dyn(tool, move |_ctx: ToolCallContext<'_, S>| {
let err = McpError::invalid_request(msg.clone(), None);
Box::pin(async move { Err(err) })
})
}
pub fn routes_gated<S>(
families: &[Box<dyn FamilyMeta>],
skills: Vec<Box<dyn Skill<S>>>,
) -> (Vec<ToolRoute<S>>, Capabilities)
where
S: Send + Sync + 'static,
{
let caps = resolve(families, &skills);
let routes = skills
.into_iter()
.map(|s| {
let cap = caps.resolved(s.name());
route_skill_gated(s, cap)
})
.collect();
(routes, caps)
}
pub fn with_extra_property(schema: &JsonObject, name: &str, fragment: Value) -> JsonObject {
let mut out = schema.clone();
let properties = out
.entry("properties".to_string())
.or_insert_with(|| serde_json::json!({}));
if let Some(props) = properties.as_object_mut() {
props.insert(name.to_string(), fragment);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Map};
fn obj(v: Value) -> JsonObject {
match v {
Value::Object(m) => m.into_iter().collect::<Map<_, _>>(),
_ => panic!("not an object"),
}
}
#[test]
fn injects_property_preserving_existing() {
let schema = obj(json!({
"type": "object",
"properties": { "x": { "type": "string" } },
"required": ["x"],
}));
let merged = with_extra_property(
&schema,
"background",
json!({ "type": "boolean", "description": "run in the background" }),
);
let props = merged
.get("properties")
.and_then(|v| v.as_object())
.unwrap();
assert!(props.contains_key("x"), "skill property preserved");
assert_eq!(
props["background"]["type"], "boolean",
"global property injected"
);
let req = merged.get("required").and_then(|v| v.as_array()).unwrap();
assert_eq!(req.len(), 1);
}
#[test]
fn creates_properties_when_absent() {
let schema = obj(json!({ "type": "object" }));
let merged = with_extra_property(&schema, "background", json!({ "type": "boolean" }));
let props = merged
.get("properties")
.and_then(|v| v.as_object())
.unwrap();
assert!(props.contains_key("background"));
}
}