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
120pub struct ToolContext {
125 pub project_root: String,
126 pub extra_roots: Vec<String>,
130 pub minimal: bool,
131 pub resolved_paths: std::collections::HashMap<String, String>,
133 pub crp_mode: crate::tools::CrpMode,
135 pub cache: Option<crate::tools::SharedCache>,
137 pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
139 pub tool_calls:
141 Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
142 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
144 pub workflow:
146 Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
147 pub ledger:
149 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
150 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
152 pub pipeline_stats:
154 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
155 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
157 pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
159 pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
161 pub path_errors: std::collections::HashMap<String, String>,
164 pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
166 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
168}
169
170impl Default for ToolContext {
171 fn default() -> Self {
175 Self {
176 project_root: String::new(),
177 extra_roots: Vec::new(),
178 minimal: false,
179 resolved_paths: std::collections::HashMap::new(),
180 crp_mode: crate::tools::CrpMode::Off,
181 cache: None,
182 session: None,
183 tool_calls: None,
184 agent_id: None,
185 workflow: None,
186 ledger: None,
187 client_name: None,
188 pipeline_stats: None,
189 call_count: None,
190 autonomy: None,
191 pressure_snapshot: None,
192 path_errors: std::collections::HashMap::new(),
193 bm25_cache: None,
194 progress_sender: None,
195 }
196 }
197}
198
199impl ToolContext {
200 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
201 self.resolved_paths.get(arg).map(String::as_str)
202 }
203
204 pub fn path_error(&self, key: &str) -> Option<&str> {
206 self.path_errors.get(key).map(String::as_str)
207 }
208
209 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
213 crate::core::path_resolve::resolve_tool_path_with_roots(
214 Some(&self.project_root),
215 None,
216 path,
217 &self.extra_roots,
218 )
219 }
220
221 pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
230 crate::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
231 }
232}
233
234pub fn require_resolved_path(
239 ctx: &ToolContext,
240 args: &Map<String, Value>,
241 key: &str,
242) -> Result<String, ErrorData> {
243 if let Some(path) = ctx.resolved_path(key) {
244 return Ok(path.to_string());
245 }
246 if let Some(err) = ctx.path_error(key) {
247 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
248 }
249 if let Some(val) = args.get(key)
250 && !val.is_string()
251 {
252 let type_name = match val {
253 Value::Number(_) => "number",
254 Value::Bool(_) => "boolean",
255 Value::Array(_) => "array",
256 Value::Object(_) => "object",
257 Value::Null => "null",
258 Value::String(_) => unreachable!(),
259 };
260 return Err(ErrorData::invalid_params(
261 format!("{key} must be a string, got {type_name}"),
262 None,
263 ));
264 }
265 Err(ErrorData::invalid_params(
266 format!("{key} is required"),
267 None,
268 ))
269}
270
271pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
272 args.get(key).and_then(|v| v.as_str()).map(String::from)
273}
274
275pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
276 args.get(key).and_then(serde_json::Value::as_i64)
277}
278
279pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
286 get_int(args, key).and_then(|n| usize::try_from(n).ok())
287}
288
289pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
290 args.get(key).and_then(serde_json::Value::as_bool)
291}
292
293pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
294 args.get(key).and_then(serde_json::Value::as_f64)
295}
296
297pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
298 args.get(key).and_then(|v| v.as_array()).map(|arr| {
299 arr.iter()
300 .filter_map(|v| v.as_str().map(String::from))
301 .collect()
302 })
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use serde_json::json;
309
310 fn empty_ctx() -> ToolContext {
311 ToolContext::default()
312 }
313
314 #[test]
315 fn require_resolved_path_returns_resolved() {
316 let mut ctx = empty_ctx();
317 ctx.resolved_paths
318 .insert("path".to_string(), "/abs/file.rs".to_string());
319 let args: Map<String, Value> = Map::new();
320 let result = require_resolved_path(&ctx, &args, "path");
321 assert_eq!(result.unwrap(), "/abs/file.rs");
322 }
323
324 #[test]
325 fn require_resolved_path_surfaces_jail_error() {
326 let mut ctx = empty_ctx();
327 ctx.path_errors.insert(
328 "path".to_string(),
329 "path escapes project root /project".to_string(),
330 );
331 let args: Map<String, Value> = Map::new();
332 let result = require_resolved_path(&ctx, &args, "path");
333 assert!(result.is_err());
334 let err = result.unwrap_err();
335 let msg = format!("{err:?}");
336 assert!(msg.contains("escapes project root"), "got: {msg}");
337 }
338
339 #[test]
340 fn require_resolved_path_detects_non_string() {
341 let ctx = empty_ctx();
342 let mut args: Map<String, Value> = Map::new();
343 args.insert("path".to_string(), json!(42));
344 let result = require_resolved_path(&ctx, &args, "path");
345 assert!(result.is_err());
346 let err = result.unwrap_err();
347 let msg = format!("{err:?}");
348 assert!(msg.contains("must be a string, got number"), "got: {msg}");
349 }
350
351 #[test]
352 fn require_resolved_path_missing_param() {
353 let ctx = empty_ctx();
354 let args: Map<String, Value> = Map::new();
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("path is required"), "got: {msg}");
360 }
361}