1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{Map, Value};
4
5pub struct ToolOutput {
7 pub text: String,
8 pub original_tokens: usize,
9 pub saved_tokens: usize,
10 pub mode: Option<String>,
11 pub path: Option<String>,
13 pub changed: bool,
16}
17
18impl ToolOutput {
19 pub fn simple(text: String) -> Self {
20 Self {
21 text,
22 original_tokens: 0,
23 saved_tokens: 0,
24 mode: None,
25 path: None,
26 changed: false,
27 }
28 }
29
30 pub fn to_header_line(&self, tool_name: &str) -> String {
32 let path_str = self.path.as_deref().unwrap_or("—");
33 let mode_str = self.mode.as_deref().unwrap_or("—");
34 let sent = self.original_tokens.saturating_sub(self.saved_tokens);
35 let pct = if self.original_tokens > 0 {
36 (self.saved_tokens as f64 / self.original_tokens as f64 * 100.0) as u32
37 } else {
38 0
39 };
40 format!("[{tool_name}: {path_str}, mode={mode_str}, {sent} tok sent, -{pct}%]")
41 }
42
43 pub fn with_savings(text: String, original: usize, saved: usize) -> Self {
44 Self {
45 text,
46 original_tokens: original,
47 saved_tokens: saved,
48 mode: None,
49 path: None,
50 changed: false,
51 }
52 }
53}
54
55pub trait McpTool: Send + Sync {
66 fn name(&self) -> &'static str;
68
69 fn tool_def(&self) -> Tool;
72
73 fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
76 -> Result<ToolOutput, ErrorData>;
77}
78
79pub struct ToolContext {
84 pub project_root: String,
85 pub minimal: bool,
86 pub resolved_paths: std::collections::HashMap<String, String>,
88 pub crp_mode: crate::tools::CrpMode,
90 pub cache: Option<crate::tools::SharedCache>,
92 pub session: Option<std::sync::Arc<tokio::sync::RwLock<crate::core::session::SessionState>>>,
94 pub tool_calls:
96 Option<std::sync::Arc<tokio::sync::RwLock<Vec<crate::core::protocol::ToolCallRecord>>>>,
97 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
99 pub workflow:
101 Option<std::sync::Arc<tokio::sync::RwLock<Option<crate::core::workflow::WorkflowRun>>>>,
102 pub ledger:
104 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::context_ledger::ContextLedger>>>,
105 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
107 pub pipeline_stats:
109 Option<std::sync::Arc<tokio::sync::RwLock<crate::core::pipeline::PipelineStats>>>,
110 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
112 pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
114 pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
116 pub path_errors: std::collections::HashMap<String, String>,
119 pub bm25_cache: Option<crate::core::bm25_cache::SharedBm25Cache>,
121 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
123}
124
125impl ToolContext {
126 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
127 self.resolved_paths.get(arg).map(String::as_str)
128 }
129
130 pub fn path_error(&self, key: &str) -> Option<&str> {
132 self.path_errors.get(key).map(String::as_str)
133 }
134
135 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
138 let normalized = crate::core::pathutil::normalize_tool_path(path);
139 if normalized.is_empty() || normalized == "." {
140 return Ok(normalized);
141 }
142 let p = std::path::Path::new(&normalized);
143 let resolved = if p.is_absolute() || p.exists() {
144 std::path::PathBuf::from(&normalized)
145 } else {
146 let joined = std::path::Path::new(&self.project_root).join(&normalized);
147 if joined.exists() {
148 joined
149 } else {
150 std::path::Path::new(&self.project_root).join(&normalized)
151 }
152 };
153 let jail_root = std::path::Path::new(&self.project_root);
154 let jailed = crate::core::pathjail::jail_path(&resolved, jail_root)?;
155 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
156 Ok(crate::core::pathutil::normalize_tool_path(
157 &jailed.to_string_lossy().replace('\\', "/"),
158 ))
159 }
160}
161
162pub fn require_resolved_path(
167 ctx: &ToolContext,
168 args: &Map<String, Value>,
169 key: &str,
170) -> Result<String, ErrorData> {
171 if let Some(path) = ctx.resolved_path(key) {
172 return Ok(path.to_string());
173 }
174 if let Some(err) = ctx.path_error(key) {
175 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
176 }
177 if let Some(val) = args.get(key) {
178 if !val.is_string() {
179 let type_name = match val {
180 Value::Number(_) => "number",
181 Value::Bool(_) => "boolean",
182 Value::Array(_) => "array",
183 Value::Object(_) => "object",
184 Value::Null => "null",
185 Value::String(_) => unreachable!(),
186 };
187 return Err(ErrorData::invalid_params(
188 format!("{key} must be a string, got {type_name}"),
189 None,
190 ));
191 }
192 }
193 Err(ErrorData::invalid_params(
194 format!("{key} is required"),
195 None,
196 ))
197}
198
199pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
200 args.get(key).and_then(|v| v.as_str()).map(String::from)
201}
202
203pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
204 args.get(key).and_then(serde_json::Value::as_i64)
205}
206
207pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
208 args.get(key).and_then(serde_json::Value::as_bool)
209}
210
211pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
212 args.get(key).and_then(|v| v.as_array()).map(|arr| {
213 arr.iter()
214 .filter_map(|v| v.as_str().map(String::from))
215 .collect()
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 fn empty_ctx() -> ToolContext {
225 ToolContext {
226 project_root: String::new(),
227 minimal: false,
228 resolved_paths: std::collections::HashMap::new(),
229 crp_mode: crate::tools::CrpMode::Off,
230 cache: None,
231 session: None,
232 tool_calls: None,
233 agent_id: None,
234 workflow: None,
235 ledger: None,
236 client_name: None,
237 pipeline_stats: None,
238 call_count: None,
239 autonomy: None,
240 pressure_snapshot: None,
241 path_errors: std::collections::HashMap::new(),
242 bm25_cache: None,
243 progress_sender: None,
244 }
245 }
246
247 #[test]
248 fn require_resolved_path_returns_resolved() {
249 let mut ctx = empty_ctx();
250 ctx.resolved_paths
251 .insert("path".to_string(), "/abs/file.rs".to_string());
252 let args: Map<String, Value> = Map::new();
253 let result = require_resolved_path(&ctx, &args, "path");
254 assert_eq!(result.unwrap(), "/abs/file.rs");
255 }
256
257 #[test]
258 fn require_resolved_path_surfaces_jail_error() {
259 let mut ctx = empty_ctx();
260 ctx.path_errors.insert(
261 "path".to_string(),
262 "path escapes project root /project".to_string(),
263 );
264 let args: Map<String, Value> = Map::new();
265 let result = require_resolved_path(&ctx, &args, "path");
266 assert!(result.is_err());
267 let err = result.unwrap_err();
268 let msg = format!("{err:?}");
269 assert!(msg.contains("escapes project root"), "got: {msg}");
270 }
271
272 #[test]
273 fn require_resolved_path_detects_non_string() {
274 let ctx = empty_ctx();
275 let mut args: Map<String, Value> = Map::new();
276 args.insert("path".to_string(), json!(42));
277 let result = require_resolved_path(&ctx, &args, "path");
278 assert!(result.is_err());
279 let err = result.unwrap_err();
280 let msg = format!("{err:?}");
281 assert!(msg.contains("must be a string, got number"), "got: {msg}");
282 }
283
284 #[test]
285 fn require_resolved_path_missing_param() {
286 let ctx = empty_ctx();
287 let args: Map<String, Value> = Map::new();
288 let result = require_resolved_path(&ctx, &args, "path");
289 assert!(result.is_err());
290 let err = result.unwrap_err();
291 let msg = format!("{err:?}");
292 assert!(msg.contains("path is required"), "got: {msg}");
293 }
294}