1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ShellOutcome {
11 Exit(i32),
13 Blocked,
16}
17
18impl ShellOutcome {
19 pub fn is_error(self) -> bool {
21 match self {
22 ShellOutcome::Exit(code) => code != 0,
23 ShellOutcome::Blocked => true,
24 }
25 }
26
27 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
40pub struct ToolOutput {
42 pub text: String,
43 pub original_tokens: usize,
44 pub saved_tokens: usize,
45 pub mode: Option<String>,
46 pub path: Option<String>,
48 pub changed: bool,
51 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 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
96pub trait McpTool: Send + Sync {
107 fn name(&self) -> &'static str;
109
110 fn tool_def(&self) -> Tool;
113
114 fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
117 -> Result<ToolOutput, ErrorData>;
118
119 fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
127 false
128 }
129}
130
131pub struct ToolContext {
136 pub project_root: String,
137 pub extra_roots: Vec<String>,
141 pub minimal: bool,
142 pub resolved_paths: std::collections::HashMap<String, String>,
144 pub crp_mode: crate::tools::CrpMode,
146 pub cache: Option<crate::tools::SharedCache>,
148 pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
150 pub tool_calls:
152 Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
153 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
155 pub workflow:
157 Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
158 pub ledger:
160 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
161 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
163 pub pipeline_stats:
165 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
166 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
168 pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
170 pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
172 pub path_errors: std::collections::HashMap<String, String>,
175 pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
177 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
179}
180
181impl Default for ToolContext {
182 fn default() -> Self {
186 Self {
187 project_root: String::new(),
188 extra_roots: Vec::new(),
189 minimal: false,
190 resolved_paths: std::collections::HashMap::new(),
191 crp_mode: crate::tools::CrpMode::Off,
192 cache: None,
193 session: None,
194 tool_calls: None,
195 agent_id: None,
196 workflow: None,
197 ledger: None,
198 client_name: None,
199 pipeline_stats: None,
200 call_count: None,
201 autonomy: None,
202 pressure_snapshot: None,
203 path_errors: std::collections::HashMap::new(),
204 bm25_cache: None,
205 progress_sender: None,
206 }
207 }
208}
209
210impl ToolContext {
211 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
212 self.resolved_paths.get(arg).map(String::as_str)
213 }
214
215 pub fn path_error(&self, key: &str) -> Option<&str> {
217 self.path_errors.get(key).map(String::as_str)
218 }
219
220 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
224 crate::core::path_resolve::resolve_tool_path_with_roots(
225 Some(&self.project_root),
226 None,
227 path,
228 &self.extra_roots,
229 )
230 }
231
232 pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
241 crate::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
242 }
243}
244
245pub fn require_resolved_path(
250 ctx: &ToolContext,
251 args: &Map<String, Value>,
252 key: &str,
253) -> Result<String, ErrorData> {
254 if let Some(path) = ctx.resolved_path(key) {
255 return Ok(path.to_string());
256 }
257 if let Some(err) = ctx.path_error(key) {
258 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
259 }
260 if let Some(val) = args.get(key)
261 && !val.is_string()
262 {
263 let type_name = match val {
264 Value::Number(_) => "number",
265 Value::Bool(_) => "boolean",
266 Value::Array(_) => "array",
267 Value::Object(_) => "object",
268 Value::Null => "null",
269 Value::String(_) => unreachable!(),
270 };
271 return Err(ErrorData::invalid_params(
272 format!("{key} must be a string, got {type_name}"),
273 None,
274 ));
275 }
276 Err(ErrorData::invalid_params(
277 format!("{key} is required"),
278 None,
279 ))
280}
281
282pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
283 args.get(key).and_then(|v| v.as_str()).map(String::from)
284}
285
286pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
287 args.get(key).and_then(serde_json::Value::as_i64)
288}
289
290pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
297 get_int(args, key).and_then(|n| usize::try_from(n).ok())
298}
299
300pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
301 args.get(key).and_then(serde_json::Value::as_bool)
302}
303
304pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
305 args.get(key).and_then(serde_json::Value::as_f64)
306}
307
308pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
309 args.get(key).and_then(|v| v.as_array()).map(|arr| {
310 arr.iter()
311 .filter_map(|v| v.as_str().map(String::from))
312 .collect()
313 })
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use serde_json::json;
320
321 fn empty_ctx() -> ToolContext {
322 ToolContext::default()
323 }
324
325 #[test]
326 fn require_resolved_path_returns_resolved() {
327 let mut ctx = empty_ctx();
328 ctx.resolved_paths
329 .insert("path".to_string(), "/abs/file.rs".to_string());
330 let args: Map<String, Value> = Map::new();
331 let result = require_resolved_path(&ctx, &args, "path");
332 assert_eq!(result.unwrap(), "/abs/file.rs");
333 }
334
335 #[test]
336 fn require_resolved_path_surfaces_jail_error() {
337 let mut ctx = empty_ctx();
338 ctx.path_errors.insert(
339 "path".to_string(),
340 "path escapes project root /project".to_string(),
341 );
342 let args: Map<String, Value> = Map::new();
343 let result = require_resolved_path(&ctx, &args, "path");
344 assert!(result.is_err());
345 let err = result.unwrap_err();
346 let msg = format!("{err:?}");
347 assert!(msg.contains("escapes project root"), "got: {msg}");
348 }
349
350 #[test]
351 fn require_resolved_path_detects_non_string() {
352 let ctx = empty_ctx();
353 let mut args: Map<String, Value> = Map::new();
354 args.insert("path".to_string(), json!(42));
355 let result = require_resolved_path(&ctx, &args, "path");
356 assert!(result.is_err());
357 let err = result.unwrap_err();
358 let msg = format!("{err:?}");
359 assert!(msg.contains("must be a string, got number"), "got: {msg}");
360 }
361
362 #[test]
363 fn require_resolved_path_missing_param() {
364 let ctx = empty_ctx();
365 let args: Map<String, Value> = Map::new();
366 let result = require_resolved_path(&ctx, &args, "path");
367 assert!(result.is_err());
368 let err = result.unwrap_err();
369 let msg = format!("{err:?}");
370 assert!(msg.contains("path is required"), "got: {msg}");
371 }
372}