Skip to main content

lean_ctx/tools/registered/
plugin_tool.rs

1//! Adapter: a manifest-declared plugin tool ([`PluginToolSpec`]) presented as a
2//! native MCP tool (EPIC 12.11). Registered dynamically in `build_registry()`,
3//! so developers add tools by shipping a manifest — never by forking.
4
5use std::sync::Arc;
6
7use rmcp::ErrorData;
8use rmcp::model::Tool;
9use serde_json::{Map, Value};
10
11use crate::core::plugins::tools::PluginToolSpec;
12use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput};
13
14/// Native MCP tool backed by a plugin's sandboxed subprocess.
15pub struct PluginTool {
16    /// Leaked, `'static` registry name (see [`PluginTool::from_spec`]).
17    name: &'static str,
18    spec: PluginToolSpec,
19}
20
21impl PluginTool {
22    /// Build a registrable tool from a discovered spec.
23    ///
24    /// The MCP registry keys tools by `&'static str`. Plugin tools are
25    /// discovered once at startup and live for the whole process, so leaking the
26    /// name is a bounded, intentional allocation (a handful of tool names).
27    #[must_use]
28    pub fn from_spec(spec: PluginToolSpec) -> Self {
29        let name: &'static str = Box::leak(spec.name.clone().into_boxed_str());
30        Self { name, spec }
31    }
32}
33
34impl McpTool for PluginTool {
35    fn name(&self) -> &'static str {
36        self.name
37    }
38
39    fn tool_def(&self) -> Tool {
40        let mut schema: Map<String, Value> = if let Value::Object(map) = &self.spec.input_schema {
41            map.clone()
42        } else {
43            let mut map = Map::new();
44            map.insert("type".to_string(), Value::String("object".to_string()));
45            map
46        };
47        // Plugin manifests are external input: harden their schemas for strict
48        // validators just like the built-in tool definitions.
49        crate::tool_defs::normalize_for_strict_validators(&mut schema);
50        let description = if self.spec.description.is_empty() {
51            format!("Plugin tool provided by '{}'", self.spec.plugin_name)
52        } else {
53            self.spec.description.clone()
54        };
55        Tool::new(self.name, description, Arc::new(schema))
56    }
57
58    fn handle(
59        &self,
60        args: &Map<String, Value>,
61        _ctx: &ToolContext,
62    ) -> Result<ToolOutput, ErrorData> {
63        let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string());
64        match crate::core::plugins::tools::invoke(&self.spec, &args_json) {
65            Ok(text) => Ok(ToolOutput::simple(text)),
66            Err(e) => Err(ErrorData::internal_error(e, None)),
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use std::path::PathBuf;
75
76    fn spec() -> PluginToolSpec {
77        PluginToolSpec {
78            plugin_name: "weather".into(),
79            plugin_dir: PathBuf::from("/tmp"),
80            name: "weather_lookup".into(),
81            description: "Look up weather".into(),
82            command: "cat".into(),
83            timeout_ms: 2000,
84            input_schema: serde_json::json!({"type": "object"}),
85            policy: crate::core::plugins::sandbox::SandboxPolicy::strict(),
86        }
87    }
88
89    #[test]
90    fn tool_def_reflects_spec() {
91        let tool = PluginTool::from_spec(spec());
92        assert_eq!(tool.name(), "weather_lookup");
93        let def = tool.tool_def();
94        assert_eq!(def.name.as_ref(), "weather_lookup");
95        assert_eq!(def.description.as_deref(), Some("Look up weather"));
96    }
97
98    #[test]
99    fn missing_description_falls_back() {
100        let mut s = spec();
101        s.description = String::new();
102        let tool = PluginTool::from_spec(s);
103        let def = tool.tool_def();
104        assert!(def.description.as_deref().unwrap_or("").contains("weather"));
105    }
106}