Skip to main content

lean_ctx/server/
tool_trait.rs

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