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
222pub fn require_resolved_path(
227 ctx: &ToolContext,
228 args: &Map<String, Value>,
229 key: &str,
230) -> Result<String, ErrorData> {
231 if let Some(path) = ctx.resolved_path(key) {
232 return Ok(path.to_string());
233 }
234 if let Some(err) = ctx.path_error(key) {
235 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
236 }
237 if let Some(val) = args.get(key)
238 && !val.is_string()
239 {
240 let type_name = match val {
241 Value::Number(_) => "number",
242 Value::Bool(_) => "boolean",
243 Value::Array(_) => "array",
244 Value::Object(_) => "object",
245 Value::Null => "null",
246 Value::String(_) => unreachable!(),
247 };
248 return Err(ErrorData::invalid_params(
249 format!("{key} must be a string, got {type_name}"),
250 None,
251 ));
252 }
253 Err(ErrorData::invalid_params(
254 format!("{key} is required"),
255 None,
256 ))
257}
258
259pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
260 args.get(key).and_then(|v| v.as_str()).map(String::from)
261}
262
263pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
264 args.get(key).and_then(serde_json::Value::as_i64)
265}
266
267pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
274 get_int(args, key).and_then(|n| usize::try_from(n).ok())
275}
276
277pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
278 args.get(key).and_then(serde_json::Value::as_bool)
279}
280
281pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
282 args.get(key).and_then(|v| v.as_array()).map(|arr| {
283 arr.iter()
284 .filter_map(|v| v.as_str().map(String::from))
285 .collect()
286 })
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use serde_json::json;
293
294 fn empty_ctx() -> ToolContext {
295 ToolContext::default()
296 }
297
298 #[test]
299 fn require_resolved_path_returns_resolved() {
300 let mut ctx = empty_ctx();
301 ctx.resolved_paths
302 .insert("path".to_string(), "/abs/file.rs".to_string());
303 let args: Map<String, Value> = Map::new();
304 let result = require_resolved_path(&ctx, &args, "path");
305 assert_eq!(result.unwrap(), "/abs/file.rs");
306 }
307
308 #[test]
309 fn require_resolved_path_surfaces_jail_error() {
310 let mut ctx = empty_ctx();
311 ctx.path_errors.insert(
312 "path".to_string(),
313 "path escapes project root /project".to_string(),
314 );
315 let args: Map<String, Value> = Map::new();
316 let result = require_resolved_path(&ctx, &args, "path");
317 assert!(result.is_err());
318 let err = result.unwrap_err();
319 let msg = format!("{err:?}");
320 assert!(msg.contains("escapes project root"), "got: {msg}");
321 }
322
323 #[test]
324 fn require_resolved_path_detects_non_string() {
325 let ctx = empty_ctx();
326 let mut args: Map<String, Value> = Map::new();
327 args.insert("path".to_string(), json!(42));
328 let result = require_resolved_path(&ctx, &args, "path");
329 assert!(result.is_err());
330 let err = result.unwrap_err();
331 let msg = format!("{err:?}");
332 assert!(msg.contains("must be a string, got number"), "got: {msg}");
333 }
334
335 #[test]
336 fn require_resolved_path_missing_param() {
337 let ctx = empty_ctx();
338 let args: Map<String, Value> = Map::new();
339 let result = require_resolved_path(&ctx, &args, "path");
340 assert!(result.is_err());
341 let err = result.unwrap_err();
342 let msg = format!("{err:?}");
343 assert!(msg.contains("path is required"), "got: {msg}");
344 }
345}