Skip to main content

lean_ctx/server/
tool_trait.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{Map, Value};
4
5/// Result returned by an McpTool handler.
6pub struct ToolOutput {
7    pub text: String,
8    pub original_tokens: usize,
9    pub saved_tokens: usize,
10    pub mode: Option<String>,
11    /// Path associated with the tool call (for record_call_with_path).
12    pub path: Option<String>,
13    /// True when the tool mutated state that clients should know about
14    /// (e.g. dynamic tool categories changed).
15    pub changed: bool,
16}
17
18impl ToolOutput {
19    pub fn simple(text: String) -> Self {
20        Self {
21            text,
22            original_tokens: 0,
23            saved_tokens: 0,
24            mode: None,
25            path: None,
26            changed: false,
27        }
28    }
29
30    /// Compact one-line summary for headers_only response verbosity.
31    pub fn to_header_line(&self, tool_name: &str) -> String {
32        let path_str = self.path.as_deref().unwrap_or("—");
33        let mode_str = self.mode.as_deref().unwrap_or("—");
34        let sent = self.original_tokens.saturating_sub(self.saved_tokens);
35        let pct = if self.original_tokens > 0 {
36            (self.saved_tokens as f64 / self.original_tokens as f64 * 100.0) as u32
37        } else {
38            0
39        };
40        format!("[{tool_name}: {path_str}, mode={mode_str}, {sent} tok sent, -{pct}%]")
41    }
42
43    pub fn with_savings(text: String, original: usize, saved: usize) -> Self {
44        Self {
45            text,
46            original_tokens: original,
47            saved_tokens: saved,
48            mode: None,
49            path: None,
50            changed: false,
51        }
52    }
53}
54
55/// Trait for a self-contained MCP tool. Each tool provides its own schema
56/// definition and handler, eliminating the possibility of schema/handler drift.
57///
58/// This trait is the plugin interface for LcpTools: any implementation can be
59/// registered at runtime via `ToolRegistry::register()`. Future plugin system
60/// will load implementations from shared libraries or subprocess bridges.
61///
62/// Handlers are synchronous because all existing tool handlers are sync.
63/// The async boundary (cache locks, session reads) is handled by the dispatch
64/// layer before calling `handle`.
65pub trait McpTool: Send + Sync {
66    /// Tool name as registered in the MCP protocol (e.g. "ctx_tree").
67    fn name(&self) -> &'static str;
68
69    /// MCP tool definition including JSON schema. This replaces the
70    /// corresponding entry in `granular_tool_defs()`.
71    fn tool_def(&self) -> Tool;
72
73    /// Execute the tool. Args are the raw JSON-RPC arguments.
74    /// `ctx` provides access to resolved paths and project state.
75    fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
76        -> Result<ToolOutput, ErrorData>;
77}
78
79/// Context passed to tool handlers. Contains pre-resolved values that
80/// many tools need, avoiding repeated async lock acquisition inside
81/// handlers. Extended with shared server state for tools that need
82/// cache/session access.
83pub struct ToolContext {
84    pub project_root: String,
85    pub minimal: bool,
86    /// Pre-resolved paths keyed by argument name (e.g. "path" -> "/abs/dir").
87    pub resolved_paths: std::collections::HashMap<String, String>,
88    /// CRP mode for compression-aware tools.
89    pub crp_mode: crate::tools::CrpMode,
90    /// Shared cache handle for tools that need read/write access.
91    pub cache: Option<crate::tools::SharedCache>,
92    /// Shared session handle for tools that need session access.
93    pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
94    /// Tool call records for session-aware tools (e.g. ctx_session status).
95    pub tool_calls:
96        Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
97    /// Current agent identity for multi-agent tools.
98    pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
99    /// Active workflow run state.
100    pub workflow:
101        Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
102    /// Context ledger for handoff operations.
103    pub ledger:
104        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
105    /// Client name (cursor, claude, etc.).
106    pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
107    /// Pipeline stats for metrics/proof tools.
108    pub pipeline_stats:
109        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
110    /// Global call counter for context tools.
111    pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
112    /// Autonomy state for search repeat detection.
113    pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
114    /// Pre-computed context pressure snapshot for synchronous gate decisions.
115    pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
116    /// Errors from path resolution (PathJail rejection, secret path, etc.).
117    /// Keyed by argument name (e.g. "path" -> "path escapes project root: ...").
118    pub path_errors: std::collections::HashMap<String, String>,
119    /// Shared in-memory BM25 index cache for semantic search.
120    pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
121    /// MCP progress notification sender for long-running operations.
122    pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
123}
124
125impl ToolContext {
126    pub fn resolved_path(&self, arg: &str) -> Option<&str> {
127        self.resolved_paths.get(arg).map(String::as_str)
128    }
129
130    /// Returns the path resolution error for a given key, if any.
131    pub fn path_error(&self, key: &str) -> Option<&str> {
132        self.path_errors.get(key).map(String::as_str)
133    }
134
135    /// Sync path resolution using project_root. Simplified version
136    /// of LeanCtxServer::resolve_path for use in sync tool handlers.
137    pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
138        let normalized = crate::core::pathutil::normalize_tool_path(path);
139        if normalized.is_empty() || normalized == "." {
140            return Ok(normalized);
141        }
142        let p = std::path::Path::new(&normalized);
143        let resolved = if p.is_absolute() || p.exists() {
144            std::path::PathBuf::from(&normalized)
145        } else {
146            let joined = std::path::Path::new(&self.project_root).join(&normalized);
147            if joined.exists() {
148                joined
149            } else {
150                std::path::Path::new(&self.project_root).join(&normalized)
151            }
152        };
153        let jail_root = std::path::Path::new(&self.project_root);
154        let jailed = crate::core::pathjail::jail_path(&resolved, jail_root)?;
155        crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
156        Ok(crate::core::pathutil::normalize_tool_path(
157            &jailed.to_string_lossy().replace('\\', "/"),
158        ))
159    }
160}
161
162// ── Arg extraction helpers (mirror server/helpers.rs for standalone use) ──
163
164/// Extract a resolved path from context with differentiated error messages.
165/// Returns descriptive errors for: missing param, PathJail rejection, wrong type.
166pub fn require_resolved_path(
167    ctx: &ToolContext,
168    args: &Map<String, Value>,
169    key: &str,
170) -> Result<String, ErrorData> {
171    if let Some(path) = ctx.resolved_path(key) {
172        return Ok(path.to_string());
173    }
174    if let Some(err) = ctx.path_error(key) {
175        return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
176    }
177    if let Some(val) = args.get(key) {
178        if !val.is_string() {
179            let type_name = match val {
180                Value::Number(_) => "number",
181                Value::Bool(_) => "boolean",
182                Value::Array(_) => "array",
183                Value::Object(_) => "object",
184                Value::Null => "null",
185                Value::String(_) => unreachable!(),
186            };
187            return Err(ErrorData::invalid_params(
188                format!("{key} must be a string, got {type_name}"),
189                None,
190            ));
191        }
192    }
193    Err(ErrorData::invalid_params(
194        format!("{key} is required"),
195        None,
196    ))
197}
198
199pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
200    args.get(key).and_then(|v| v.as_str()).map(String::from)
201}
202
203pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
204    args.get(key).and_then(serde_json::Value::as_i64)
205}
206
207pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
208    args.get(key).and_then(serde_json::Value::as_bool)
209}
210
211pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
212    args.get(key).and_then(|v| v.as_array()).map(|arr| {
213        arr.iter()
214            .filter_map(|v| v.as_str().map(String::from))
215            .collect()
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use serde_json::json;
223
224    fn empty_ctx() -> ToolContext {
225        ToolContext {
226            project_root: String::new(),
227            minimal: false,
228            resolved_paths: std::collections::HashMap::new(),
229            crp_mode: crate::tools::CrpMode::Off,
230            cache: None,
231            session: None,
232            tool_calls: None,
233            agent_id: None,
234            workflow: None,
235            ledger: None,
236            client_name: None,
237            pipeline_stats: None,
238            call_count: None,
239            autonomy: None,
240            pressure_snapshot: None,
241            path_errors: std::collections::HashMap::new(),
242            bm25_cache: None,
243            progress_sender: None,
244        }
245    }
246
247    #[test]
248    fn require_resolved_path_returns_resolved() {
249        let mut ctx = empty_ctx();
250        ctx.resolved_paths
251            .insert("path".to_string(), "/abs/file.rs".to_string());
252        let args: Map<String, Value> = Map::new();
253        let result = require_resolved_path(&ctx, &args, "path");
254        assert_eq!(result.unwrap(), "/abs/file.rs");
255    }
256
257    #[test]
258    fn require_resolved_path_surfaces_jail_error() {
259        let mut ctx = empty_ctx();
260        ctx.path_errors.insert(
261            "path".to_string(),
262            "path escapes project root /project".to_string(),
263        );
264        let args: Map<String, Value> = Map::new();
265        let result = require_resolved_path(&ctx, &args, "path");
266        assert!(result.is_err());
267        let err = result.unwrap_err();
268        let msg = format!("{err:?}");
269        assert!(msg.contains("escapes project root"), "got: {msg}");
270    }
271
272    #[test]
273    fn require_resolved_path_detects_non_string() {
274        let ctx = empty_ctx();
275        let mut args: Map<String, Value> = Map::new();
276        args.insert("path".to_string(), json!(42));
277        let result = require_resolved_path(&ctx, &args, "path");
278        assert!(result.is_err());
279        let err = result.unwrap_err();
280        let msg = format!("{err:?}");
281        assert!(msg.contains("must be a string, got number"), "got: {msg}");
282    }
283
284    #[test]
285    fn require_resolved_path_missing_param() {
286        let ctx = empty_ctx();
287        let args: Map<String, Value> = Map::new();
288        let result = require_resolved_path(&ctx, &args, "path");
289        assert!(result.is_err());
290        let err = result.unwrap_err();
291        let msg = format!("{err:?}");
292        assert!(msg.contains("path is required"), "got: {msg}");
293    }
294}