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::model::Tool;
8use rmcp::ErrorData;
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(&Value::Object(args.clone()))
64            .unwrap_or_else(|_| "{}".to_string());
65        match crate::core::plugins::tools::invoke(&self.spec, &args_json) {
66            Ok(text) => Ok(ToolOutput::simple(text)),
67            Err(e) => Err(ErrorData::internal_error(e, None)),
68        }
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use std::path::PathBuf;
76
77    fn spec() -> PluginToolSpec {
78        PluginToolSpec {
79            plugin_name: "weather".into(),
80            plugin_dir: PathBuf::from("/tmp"),
81            name: "weather_lookup".into(),
82            description: "Look up weather".into(),
83            command: "cat".into(),
84            timeout_ms: 2000,
85            input_schema: serde_json::json!({"type": "object"}),
86            policy: crate::core::plugins::sandbox::SandboxPolicy::strict(),
87        }
88    }
89
90    #[test]
91    fn tool_def_reflects_spec() {
92        let tool = PluginTool::from_spec(spec());
93        assert_eq!(tool.name(), "weather_lookup");
94        let def = tool.tool_def();
95        assert_eq!(def.name.as_ref(), "weather_lookup");
96        assert_eq!(def.description.as_deref(), Some("Look up weather"));
97    }
98
99    #[test]
100    fn missing_description_falls_back() {
101        let mut s = spec();
102        s.description = String::new();
103        let tool = PluginTool::from_spec(s);
104        let def = tool.tool_def();
105        assert!(def.description.as_deref().unwrap_or("").contains("weather"));
106    }
107}