Skip to main content

lean_ctx/server/
tool_trait.rs

1use rmcp::ErrorData;
2use rmcp::model::{ContentBlock, Tool};
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    /// Override content blocks for non-text responses (e.g. images).
56    /// When set, dispatch uses these instead of wrapping `text` in TextContent.
57    pub content_blocks: Option<Vec<ContentBlock>>,
58}
59
60impl ToolOutput {
61    pub fn simple(text: String) -> Self {
62        Self {
63            text,
64            original_tokens: 0,
65            saved_tokens: 0,
66            mode: None,
67            path: None,
68            changed: false,
69            shell_outcome: None,
70            content_blocks: None,
71        }
72    }
73
74    /// Compact one-line summary for headers_only response verbosity.
75    pub fn to_header_line(&self, tool_name: &str) -> String {
76        let path_str = self.path.as_deref().unwrap_or("—");
77        let mode_str = self.mode.as_deref().unwrap_or("—");
78        let sent = self.original_tokens.saturating_sub(self.saved_tokens);
79        let pct = if self.original_tokens > 0 {
80            (self.saved_tokens as f64 / self.original_tokens as f64 * 100.0) as u32
81        } else {
82            0
83        };
84        format!("[{tool_name}: {path_str}, mode={mode_str}, {sent} tok sent, -{pct}%]")
85    }
86
87    pub fn with_savings(text: String, original: usize, saved: usize) -> Self {
88        Self {
89            text,
90            original_tokens: original,
91            saved_tokens: saved,
92            mode: None,
93            path: None,
94            changed: false,
95            shell_outcome: None,
96            content_blocks: None,
97        }
98    }
99
100    /// Construct a ToolOutput for image/binary content blocks.
101    /// Bypasses all text processing in the dispatch pipeline.
102    pub fn image(blocks: Vec<ContentBlock>, path: String) -> Self {
103        Self {
104            text: String::new(),
105            original_tokens: 0,
106            saved_tokens: 0,
107            mode: Some("image".to_string()),
108            path: Some(path),
109            changed: false,
110            shell_outcome: None,
111            content_blocks: Some(blocks),
112        }
113    }
114}
115
116/// Trait for a self-contained MCP tool. Each tool provides its own schema
117/// definition and handler, eliminating the possibility of schema/handler drift.
118///
119/// This trait is the plugin interface for LcpTools: any implementation can be
120/// registered at runtime via `ToolRegistry::register()`. Future plugin system
121/// will load implementations from shared libraries or subprocess bridges.
122///
123/// Handlers are synchronous because all existing tool handlers are sync.
124/// The async boundary (cache locks, session reads) is handled by the dispatch
125/// layer before calling `handle`.
126pub trait McpTool: Send + Sync {
127    /// Tool name as registered in the MCP protocol (e.g. "ctx_tree").
128    fn name(&self) -> &'static str;
129
130    /// MCP tool definition including JSON schema. This replaces the
131    /// corresponding entry in `granular_tool_defs()`.
132    fn tool_def(&self) -> Tool;
133
134    /// Execute the tool. Args are the raw JSON-RPC arguments.
135    /// `ctx` provides access to resolved paths and project state.
136    fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
137    -> Result<ToolOutput, ErrorData>;
138
139    /// Whether *this* invocation yields a machine-readable payload (e.g. JSON)
140    /// that must reach the client byte-exact and parseable. When `true`, the
141    /// dispatch pipeline suppresses every human-oriented decoration
142    /// (auto-context briefing, verify footer, hints, checkpoints, deprecation
143    /// notices) and terse compression for this call, returning the pure body.
144    /// Keyed on `args` because most tools emit machine-readable output only for
145    /// an opt-in format flag (e.g. `format=json`); defaults to `false` (#990).
146    fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
147        false
148    }
149}
150
151/// Context passed to tool handlers. Contains pre-resolved values that
152/// many tools need, avoiding repeated async lock acquisition inside
153/// handlers. Extended with shared server state for tools that need
154/// cache/session access.
155///
156/// `Clone` exists for per-op delegation (#1088): a batch op with its own
157/// `path` clones the ctx and swaps in that op's resolved path.
158#[derive(Clone)]
159pub struct ToolContext {
160    pub project_root: String,
161    /// Session-scoped trusted roots (MCP `roots/list`, config `extra_roots`),
162    /// snapshotted from the session so sync handlers can honor them without an
163    /// async lock. Empty = single-root jail behaviour (#403).
164    pub extra_roots: Vec<String>,
165    pub minimal: bool,
166    /// Pre-resolved paths keyed by argument name (e.g. "path" -> "/abs/dir").
167    pub resolved_paths: std::collections::HashMap<String, String>,
168    /// CRP mode for compression-aware tools.
169    pub crp_mode: crate::tools::CrpMode,
170    /// Shared cache handle for tools that need read/write access.
171    pub cache: Option<crate::tools::SharedCache>,
172    /// Shared session handle for tools that need session access.
173    pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
174    /// Tool call records for session-aware tools (e.g. ctx_session status).
175    pub tool_calls:
176        Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
177    /// Current agent identity for multi-agent tools.
178    pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
179    /// Active workflow run state.
180    pub workflow:
181        Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
182    /// Context ledger for handoff operations.
183    pub ledger:
184        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
185    /// Client name (cursor, claude, etc.).
186    pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
187    /// Optional MCP client role supplied by the session/request context.
188    /// Absent preserves the backward-compatible permissive default.
189    pub client_role: Option<String>,
190    /// Optional shell permission supplied by the session/request context.
191    /// `None` preserves the backward-compatible permissive default.
192    pub shell_access: Option<bool>,
193    /// Pipeline stats for metrics/proof tools.
194    pub pipeline_stats:
195        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
196    /// Global call counter for context tools.
197    pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
198    /// Autonomy state for search repeat detection.
199    pub autonomy: Option<std::sync::Arc<crate::core::autonomy::AutonomyState>>,
200    /// Pre-computed context pressure snapshot for synchronous gate decisions.
201    pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
202    /// Errors from path resolution (PathJail rejection, secret path, etc.).
203    /// Keyed by argument name (e.g. "path" -> "path escapes project root: ...").
204    pub path_errors: std::collections::HashMap<String, String>,
205    /// Shared in-memory BM25 index cache for semantic search.
206    pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
207    /// MCP progress notification sender for long-running operations.
208    pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
209}
210
211impl Default for ToolContext {
212    /// Minimal context: `project_root` empty, all shared-state handles `None`.
213    /// Single source for the one-shot CLI ctx (`cli::call_cmd::oneshot_ctx`) and
214    /// the `empty_ctx` test helper — adding a field no longer breaks either.
215    fn default() -> Self {
216        Self {
217            project_root: String::new(),
218            extra_roots: Vec::new(),
219            minimal: false,
220            resolved_paths: std::collections::HashMap::new(),
221            crp_mode: crate::tools::CrpMode::Off,
222            cache: None,
223            session: None,
224            tool_calls: None,
225            agent_id: None,
226            workflow: None,
227            ledger: None,
228            client_name: None,
229            client_role: None,
230            shell_access: None,
231            pipeline_stats: None,
232            call_count: None,
233            autonomy: None,
234            pressure_snapshot: None,
235            path_errors: std::collections::HashMap::new(),
236            bm25_cache: None,
237            progress_sender: None,
238        }
239    }
240}
241
242impl ToolContext {
243    pub fn resolved_path(&self, arg: &str) -> Option<&str> {
244        self.resolved_paths.get(arg).map(String::as_str)
245    }
246
247    /// Returns the path resolution error for a given key, if any.
248    pub fn path_error(&self, key: &str) -> Option<&str> {
249        self.path_errors.get(key).map(String::as_str)
250    }
251
252    /// Sync path resolution using `project_root` + session `extra_roots`. Thin
253    /// wrapper over [`crate::core::path_resolve::resolve_tool_path_with_roots`]
254    /// for sync tool handlers.
255    pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
256        crate::core::path_resolve::resolve_tool_path_with_roots(
257            Some(&self.project_root),
258            None,
259            path,
260            &self.extra_roots,
261        )
262    }
263
264    /// Default-deny write gate for the read-only tier (#475). Write-capable tool
265    /// handlers must call this with an already-resolved absolute path before
266    /// touching the filesystem; it errors if the path is inside a configured
267    /// `read_only_roots` subtree. A no-op (always `Ok`) when no read-only roots
268    /// are configured, so non-users pay nothing. Thin wrapper over the single
269    /// choke point [`crate::core::pathjail::enforce_writable`] — the low-level
270    /// atomic writers call the same function, so this is the ergonomic,
271    /// early-error layer, not the only line of defence.
272    pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
273        crate::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
274    }
275}
276
277// ── Arg extraction helpers (mirror server/helpers.rs for standalone use) ──
278
279/// Extract a resolved path from context with differentiated error messages.
280/// Returns descriptive errors for: missing param, PathJail rejection, wrong type.
281pub fn require_resolved_path(
282    ctx: &ToolContext,
283    args: &Map<String, Value>,
284    key: &str,
285) -> Result<String, ErrorData> {
286    if let Some(path) = ctx.resolved_path(key) {
287        return Ok(path.to_string());
288    }
289    if let Some(err) = ctx.path_error(key) {
290        return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
291    }
292    if let Some(val) = args.get(key)
293        && !val.is_string()
294    {
295        let type_name = match val {
296            Value::Number(_) => "number",
297            Value::Bool(_) => "boolean",
298            Value::Array(_) => "array",
299            Value::Object(_) => "object",
300            Value::Null => "null",
301            Value::String(_) => unreachable!(),
302        };
303        return Err(ErrorData::invalid_params(
304            format!("{key} must be a string, got {type_name}"),
305            None,
306        ));
307    }
308    Err(ErrorData::invalid_params(
309        format!("{key} is required"),
310        None,
311    ))
312}
313
314pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
315    args.get(key).and_then(|v| v.as_str()).map(String::from)
316}
317
318pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
319    args.get(key)
320        .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
321}
322
323/// Read a non-negative integer argument as `usize`.
324///
325/// Returns `None` for missing or negative values. This avoids the
326/// `negative_i64 as usize` wrap to `usize::MAX`, which previously let an agent
327/// trigger unbounded allocations (e.g. `top_k`, `limit`, `max_results`) → OOM.
328/// Callers should still apply a sensible upper cap on the result.
329pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
330    get_int(args, key).and_then(|n| usize::try_from(n).ok())
331}
332
333pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
334    args.get(key).and_then(serde_json::Value::as_bool)
335}
336
337pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
338    args.get(key).and_then(serde_json::Value::as_f64)
339}
340
341pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
342    args.get(key).and_then(|v| v.as_array()).map(|arr| {
343        arr.iter()
344            .filter_map(|v| v.as_str().map(String::from))
345            .collect()
346    })
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    fn empty_ctx() -> ToolContext {
355        ToolContext::default()
356    }
357
358    #[test]
359    fn require_resolved_path_returns_resolved() {
360        let mut ctx = empty_ctx();
361        ctx.resolved_paths
362            .insert("path".to_string(), "/abs/file.rs".to_string());
363        let args: Map<String, Value> = Map::new();
364        let result = require_resolved_path(&ctx, &args, "path");
365        assert_eq!(result.unwrap(), "/abs/file.rs");
366    }
367
368    #[test]
369    fn require_resolved_path_surfaces_jail_error() {
370        let mut ctx = empty_ctx();
371        ctx.path_errors.insert(
372            "path".to_string(),
373            "path escapes project root /project".to_string(),
374        );
375        let args: Map<String, Value> = Map::new();
376        let result = require_resolved_path(&ctx, &args, "path");
377        assert!(result.is_err());
378        let err = result.unwrap_err();
379        let msg = format!("{err:?}");
380        assert!(msg.contains("escapes project root"), "got: {msg}");
381    }
382
383    #[test]
384    fn require_resolved_path_detects_non_string() {
385        let ctx = empty_ctx();
386        let mut args: Map<String, Value> = Map::new();
387        args.insert("path".to_string(), json!(42));
388        let result = require_resolved_path(&ctx, &args, "path");
389        assert!(result.is_err());
390        let err = result.unwrap_err();
391        let msg = format!("{err:?}");
392        assert!(msg.contains("must be a string, got number"), "got: {msg}");
393    }
394
395    #[test]
396    fn require_resolved_path_missing_param() {
397        let ctx = empty_ctx();
398        let args: Map<String, Value> = Map::new();
399        let result = require_resolved_path(&ctx, &args, "path");
400        assert!(result.is_err());
401        let err = result.unwrap_err();
402        let msg = format!("{err:?}");
403        assert!(msg.contains("path is required"), "got: {msg}");
404    }
405
406    #[test]
407    fn get_int_coerces_string_to_number() {
408        let mut args = Map::new();
409        args.insert("n".into(), json!("42"));
410        assert_eq!(super::get_int(&args, "n"), Some(42));
411    }
412
413    #[test]
414    fn get_int_native_number_still_works() {
415        let mut args = Map::new();
416        args.insert("n".into(), json!(7));
417        assert_eq!(super::get_int(&args, "n"), Some(7));
418    }
419
420    #[test]
421    fn get_int_invalid_string_returns_none() {
422        let mut args = Map::new();
423        args.insert("n".into(), json!("not_a_number"));
424        assert_eq!(super::get_int(&args, "n"), None);
425    }
426
427    #[test]
428    fn get_usize_coerces_string() {
429        let mut args = Map::new();
430        args.insert("n".into(), json!("100"));
431        assert_eq!(super::get_usize(&args, "n"), Some(100));
432    }
433
434    #[test]
435    fn get_usize_negative_string_returns_none() {
436        let mut args = Map::new();
437        args.insert("n".into(), json!("-5"));
438        assert_eq!(super::get_usize(&args, "n"), None);
439    }
440}