Skip to main content

lean_ctx/core/plugins/
tools.rs

1//! Manifest-declared tools (EPIC 12.11).
2//!
3//! Flattens `[[tools]]` entries from enabled plugins into [`PluginToolSpec`]s
4//! and invokes them as sandboxed subprocesses. The tool layer adapts each spec
5//! into a native MCP tool (`tools::registered::plugin_tool`) and registers it
6//! dynamically in `build_registry()` — so a developer adds a tool by shipping a
7//! manifest, never by forking the registry.
8
9use std::path::PathBuf;
10use std::time::Duration;
11
12use serde_json::Value;
13
14use super::sandbox::SandboxPolicy;
15
16/// A flattened, ready-to-register tool contributed by a plugin manifest.
17#[derive(Debug, Clone)]
18pub struct PluginToolSpec {
19    /// Owning plugin name (for diagnostics + capabilities).
20    pub plugin_name: String,
21    /// Plugin directory, exported to the child as `LEAN_CTX_PLUGIN_DIR`.
22    pub plugin_dir: PathBuf,
23    /// Tool name as exposed to agents.
24    pub name: String,
25    /// Human-readable description.
26    pub description: String,
27    /// Command to run (whitespace-split into program + args).
28    pub command: String,
29    /// Per-call timeout.
30    pub timeout_ms: u64,
31    /// JSON Schema for the tool's arguments.
32    pub input_schema: Value,
33    /// Sandbox policy inherited from the owning plugin's `[trust]` (EPIC 12.3).
34    pub policy: SandboxPolicy,
35}
36
37/// Invoke a plugin tool: the JSON `args_json` is written to the child's stdin
38/// and stdout is returned as the tool result. Runs sandboxed with the shared
39/// subprocess runner (piped stdio + bounded timeout).
40pub fn invoke(spec: &PluginToolSpec, args_json: &str) -> Result<String, String> {
41    let output = super::executor::run_subprocess(
42        &spec.command,
43        &spec.plugin_dir,
44        &[("LEAN_CTX_TOOL", spec.name.as_str())],
45        args_json,
46        Duration::from_millis(spec.timeout_ms),
47        &spec.policy,
48    )?;
49
50    if output.status.success() {
51        Ok(String::from_utf8_lossy(&output.stdout).to_string())
52    } else {
53        let stderr = String::from_utf8_lossy(&output.stderr);
54        Err(if stderr.trim().is_empty() {
55            format!("tool '{}' exited with {}", spec.name, output.status)
56        } else {
57            format!("tool '{}': {}", spec.name, stderr.trim())
58        })
59    }
60}
61
62// Every test here shells out to unix-only commands (`cat`, `false`), so the
63// whole module is unix-gated. Gating the module (rather than each item) keeps
64// Windows free of dead-code / unused-import errors under `-D warnings`.
65#[cfg(all(test, unix))]
66mod tests {
67    use super::*;
68
69    fn spec(command: &str) -> PluginToolSpec {
70        PluginToolSpec {
71            plugin_name: "demo".into(),
72            plugin_dir: PathBuf::from("/tmp"),
73            name: "demo_tool".into(),
74            description: "demo".into(),
75            command: command.into(),
76            timeout_ms: 2000,
77            input_schema: Value::Null,
78            policy: SandboxPolicy::strict(),
79        }
80    }
81
82    #[test]
83    fn invoke_returns_stdout() {
84        let out = invoke(&spec("cat"), "{\"q\":1}").unwrap();
85        assert_eq!(out, "{\"q\":1}");
86    }
87
88    #[test]
89    fn invoke_reports_failure() {
90        // `false` exits non-zero with no stdout/stderr.
91        let err = invoke(&spec("false"), "{}").unwrap_err();
92        assert!(err.contains("demo_tool"));
93    }
94}