1use rmcp::ErrorData;
2use rmcp::model::{ContentBlock, 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 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 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 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
116pub trait McpTool: Send + Sync {
127 fn name(&self) -> &'static str;
129
130 fn tool_def(&self) -> Tool;
133
134 fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
137 -> Result<ToolOutput, ErrorData>;
138
139 fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
147 false
148 }
149}
150
151#[derive(Clone)]
159pub struct ToolContext {
160 pub project_root: String,
161 pub extra_roots: Vec<String>,
165 pub minimal: bool,
166 pub resolved_paths: std::collections::HashMap<String, String>,
168 pub crp_mode: crate::tools::CrpMode,
170 pub cache: Option<crate::tools::SharedCache>,
172 pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
174 pub tool_calls:
176 Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
177 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
179 pub workflow:
181 Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
182 pub ledger:
184 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
185 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
187 pub client_role: Option<String>,
190 pub shell_access: Option<bool>,
193 pub pipeline_stats:
195 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
196 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
198 pub autonomy: Option<std::sync::Arc<crate::core::autonomy::AutonomyState>>,
200 pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
202 pub path_errors: std::collections::HashMap<String, String>,
205 pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
207 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
209}
210
211impl Default for ToolContext {
212 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 pub fn path_error(&self, key: &str) -> Option<&str> {
249 self.path_errors.get(key).map(String::as_str)
250 }
251
252 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 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
277pub 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
323pub 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}