1use rmcp::model::Tool;
2use rmcp::ErrorData;
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 minimal: bool,
127 pub resolved_paths: std::collections::HashMap<String, String>,
129 pub crp_mode: crate::tools::CrpMode,
131 pub cache: Option<crate::tools::SharedCache>,
133 pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
135 pub tool_calls:
137 Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
138 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
140 pub workflow:
142 Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
143 pub ledger:
145 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
146 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
148 pub pipeline_stats:
150 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
151 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
153 pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
155 pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
157 pub path_errors: std::collections::HashMap<String, String>,
160 pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
162 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
164}
165
166impl ToolContext {
167 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
168 self.resolved_paths.get(arg).map(String::as_str)
169 }
170
171 pub fn path_error(&self, key: &str) -> Option<&str> {
173 self.path_errors.get(key).map(String::as_str)
174 }
175
176 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
179 crate::core::path_resolve::resolve_tool_path(Some(&self.project_root), None, path)
180 }
181}
182
183pub fn require_resolved_path(
188 ctx: &ToolContext,
189 args: &Map<String, Value>,
190 key: &str,
191) -> Result<String, ErrorData> {
192 if let Some(path) = ctx.resolved_path(key) {
193 return Ok(path.to_string());
194 }
195 if let Some(err) = ctx.path_error(key) {
196 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
197 }
198 if let Some(val) = args.get(key) {
199 if !val.is_string() {
200 let type_name = match val {
201 Value::Number(_) => "number",
202 Value::Bool(_) => "boolean",
203 Value::Array(_) => "array",
204 Value::Object(_) => "object",
205 Value::Null => "null",
206 Value::String(_) => unreachable!(),
207 };
208 return Err(ErrorData::invalid_params(
209 format!("{key} must be a string, got {type_name}"),
210 None,
211 ));
212 }
213 }
214 Err(ErrorData::invalid_params(
215 format!("{key} is required"),
216 None,
217 ))
218}
219
220pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
221 args.get(key).and_then(|v| v.as_str()).map(String::from)
222}
223
224pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
225 args.get(key).and_then(serde_json::Value::as_i64)
226}
227
228pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
235 get_int(args, key).and_then(|n| usize::try_from(n).ok())
236}
237
238pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
239 args.get(key).and_then(serde_json::Value::as_bool)
240}
241
242pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
243 args.get(key).and_then(|v| v.as_array()).map(|arr| {
244 arr.iter()
245 .filter_map(|v| v.as_str().map(String::from))
246 .collect()
247 })
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 fn empty_ctx() -> ToolContext {
256 ToolContext {
257 project_root: String::new(),
258 minimal: false,
259 resolved_paths: std::collections::HashMap::new(),
260 crp_mode: crate::tools::CrpMode::Off,
261 cache: None,
262 session: None,
263 tool_calls: None,
264 agent_id: None,
265 workflow: None,
266 ledger: None,
267 client_name: None,
268 pipeline_stats: None,
269 call_count: None,
270 autonomy: None,
271 pressure_snapshot: None,
272 path_errors: std::collections::HashMap::new(),
273 bm25_cache: None,
274 progress_sender: None,
275 }
276 }
277
278 #[test]
279 fn require_resolved_path_returns_resolved() {
280 let mut ctx = empty_ctx();
281 ctx.resolved_paths
282 .insert("path".to_string(), "/abs/file.rs".to_string());
283 let args: Map<String, Value> = Map::new();
284 let result = require_resolved_path(&ctx, &args, "path");
285 assert_eq!(result.unwrap(), "/abs/file.rs");
286 }
287
288 #[test]
289 fn require_resolved_path_surfaces_jail_error() {
290 let mut ctx = empty_ctx();
291 ctx.path_errors.insert(
292 "path".to_string(),
293 "path escapes project root /project".to_string(),
294 );
295 let args: Map<String, Value> = Map::new();
296 let result = require_resolved_path(&ctx, &args, "path");
297 assert!(result.is_err());
298 let err = result.unwrap_err();
299 let msg = format!("{err:?}");
300 assert!(msg.contains("escapes project root"), "got: {msg}");
301 }
302
303 #[test]
304 fn require_resolved_path_detects_non_string() {
305 let ctx = empty_ctx();
306 let mut args: Map<String, Value> = Map::new();
307 args.insert("path".to_string(), json!(42));
308 let result = require_resolved_path(&ctx, &args, "path");
309 assert!(result.is_err());
310 let err = result.unwrap_err();
311 let msg = format!("{err:?}");
312 assert!(msg.contains("must be a string, got number"), "got: {msg}");
313 }
314
315 #[test]
316 fn require_resolved_path_missing_param() {
317 let ctx = empty_ctx();
318 let args: Map<String, Value> = Map::new();
319 let result = require_resolved_path(&ctx, &args, "path");
320 assert!(result.is_err());
321 let err = result.unwrap_err();
322 let msg = format!("{err:?}");
323 assert!(msg.contains("path is required"), "got: {msg}");
324 }
325}