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/// Handlers are synchronous because all existing tool handlers are sync.
59/// The async boundary (cache locks, session reads) is handled by the dispatch
60/// layer before calling `handle`.
61pub trait McpTool: Send + Sync {
62    /// Tool name as registered in the MCP protocol (e.g. "ctx_tree").
63    fn name(&self) -> &'static str;
64
65    /// MCP tool definition including JSON schema. This replaces the
66    /// corresponding entry in `granular_tool_defs()`.
67    fn tool_def(&self) -> Tool;
68
69    /// Execute the tool. Args are the raw JSON-RPC arguments.
70    /// `ctx` provides access to resolved paths and project state.
71    fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
72        -> Result<ToolOutput, ErrorData>;
73}
74
75/// Context passed to tool handlers. Contains pre-resolved values that
76/// many tools need, avoiding repeated async lock acquisition inside
77/// handlers. Extended with shared server state for tools that need
78/// cache/session access.
79pub struct ToolContext {
80    pub project_root: String,
81    pub minimal: bool,
82    /// Pre-resolved paths keyed by argument name (e.g. "path" -> "/abs/dir").
83    pub resolved_paths: std::collections::HashMap<String, String>,
84    /// CRP mode for compression-aware tools.
85    pub crp_mode: crate::tools::CrpMode,
86    /// Shared cache handle for tools that need read/write access.
87    pub cache: Option<crate::tools::SharedCache>,
88    /// Shared session handle for tools that need session access.
89    pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
90    /// Tool call records for session-aware tools (e.g. ctx_session status).
91    pub tool_calls:
92        Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
93    /// Current agent identity for multi-agent tools.
94    pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
95    /// Active workflow run state.
96    pub workflow:
97        Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
98    /// Context ledger for handoff operations.
99    pub ledger:
100        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
101    /// Client name (cursor, claude, etc.).
102    pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
103    /// Pipeline stats for metrics/proof tools.
104    pub pipeline_stats:
105        Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
106    /// Global call counter for context tools.
107    pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
108    /// Autonomy state for search repeat detection.
109    pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
110    /// Pre-computed context pressure snapshot for synchronous gate decisions.
111    pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
112}
113
114impl ToolContext {
115    pub fn resolved_path(&self, arg: &str) -> Option<&str> {
116        self.resolved_paths.get(arg).map(String::as_str)
117    }
118
119    /// Sync path resolution using project_root. Simplified version
120    /// of LeanCtxServer::resolve_path for use in sync tool handlers.
121    pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
122        let normalized = crate::core::pathutil::normalize_tool_path(path);
123        if normalized.is_empty() || normalized == "." {
124            return Ok(normalized);
125        }
126        let p = std::path::Path::new(&normalized);
127        let resolved = if p.is_absolute() || p.exists() {
128            std::path::PathBuf::from(&normalized)
129        } else {
130            let joined = std::path::Path::new(&self.project_root).join(&normalized);
131            if joined.exists() {
132                joined
133            } else {
134                std::path::Path::new(&self.project_root).join(&normalized)
135            }
136        };
137        let jail_root = std::path::Path::new(&self.project_root);
138        let jailed = crate::core::pathjail::jail_path(&resolved, jail_root)?;
139        crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
140        Ok(crate::core::pathutil::normalize_tool_path(
141            &jailed.to_string_lossy().replace('\\', "/"),
142        ))
143    }
144}
145
146// ── Arg extraction helpers (mirror server/helpers.rs for standalone use) ──
147
148pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
149    args.get(key).and_then(|v| v.as_str()).map(String::from)
150}
151
152pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
153    args.get(key).and_then(serde_json::Value::as_i64)
154}
155
156pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
157    args.get(key).and_then(serde_json::Value::as_bool)
158}
159
160pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
161    args.get(key).and_then(|v| v.as_array()).map(|arr| {
162        arr.iter()
163            .filter_map(|v| v.as_str().map(String::from))
164            .collect()
165    })
166}