1use rmcp::ErrorData;
3use rmcp::model::{ContentBlock, Tool};
4use serde_json::{Map, Value};
5
6pub const TOOL_EXECUTION_ERROR: rmcp::model::ErrorCode = rmcp::model::ErrorCode(-32071);
10
11pub(crate) fn tool_execution_error(message: String) -> ErrorData {
12 ErrorData::new(TOOL_EXECUTION_ERROR, message, None)
13}
14
15pub const PATH_LIKE_KEYS: &[&str] = &[
17 "path",
18 "project_root",
19 "root",
20 "file",
21 "directory",
22 "dir",
23 "target",
24 "source",
25 "destination",
26 "old_path",
27 "new_path",
28 "file_path",
29 "from",
30 "to",
31 "base_path",
32 "config_path",
33 "output",
34];
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BackgroundJobState {
39 Running,
40 Completed,
41 Failed,
42 Cancelled,
43}
44
45impl BackgroundJobState {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Running => "running",
49 Self::Completed => "completed",
50 Self::Failed => "failed",
51 Self::Cancelled => "cancelled",
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BackgroundDisplay {
58 pub header: String,
59 pub footer: Option<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct BackgroundShellOutcome {
65 pub state: BackgroundJobState,
66 pub exit_code: Option<i32>,
67 pub job_id: String,
68 pub archive_id: Option<String>,
69 pub archive_truncated: Option<bool>,
70 pub captured_chars: Option<usize>,
71 pub archived_chars: Option<usize>,
72 pub summary: String,
73 pub is_error: bool,
74 pub display: Option<BackgroundDisplay>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct BackgroundLookupError {
80 pub job_id: String,
81 pub code: String,
82 pub reason: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ShellOutcome {
91 Exit(i32),
93 Blocked,
96 Background(BackgroundShellOutcome),
99 BackgroundLookupError(BackgroundLookupError),
101}
102
103impl ShellOutcome {
104 pub fn is_error(&self) -> bool {
106 match self {
107 ShellOutcome::Exit(code) => *code != 0,
108 ShellOutcome::Blocked | ShellOutcome::BackgroundLookupError(_) => true,
109 ShellOutcome::Background(outcome) => outcome.is_error,
110 }
111 }
112
113 pub fn structured(&self) -> Option<serde_json::Value> {
118 match self {
119 ShellOutcome::Exit(0) => None,
120 ShellOutcome::Exit(code) => Some(serde_json::json!({ "exitCode": code })),
121 ShellOutcome::Blocked => Some(serde_json::json!({ "blocked": true })),
122 ShellOutcome::Background(outcome) => {
123 let mut fields = serde_json::Map::new();
124 fields.insert(
125 "state".to_string(),
126 serde_json::json!(outcome.state.as_str()),
127 );
128 fields.insert("jobId".to_string(), serde_json::json!(outcome.job_id));
129 fields.insert("summary".to_string(), serde_json::json!(outcome.summary));
130 if let Some(exit_code) = outcome.exit_code {
131 fields.insert("exitCode".to_string(), serde_json::json!(exit_code));
132 }
133 if let Some(archive_id) = &outcome.archive_id {
134 fields.insert("archiveId".to_string(), serde_json::json!(archive_id));
135 }
136 if let Some(archive_truncated) = outcome.archive_truncated {
137 fields.insert(
138 "archiveTruncated".to_string(),
139 serde_json::json!(archive_truncated),
140 );
141 }
142 if let Some(captured_chars) = outcome.captured_chars {
143 fields.insert(
144 "capturedChars".to_string(),
145 serde_json::json!(captured_chars),
146 );
147 }
148 if let Some(archived_chars) = outcome.archived_chars {
149 fields.insert(
150 "archivedChars".to_string(),
151 serde_json::json!(archived_chars),
152 );
153 }
154 Some(serde_json::Value::Object(fields))
155 }
156 ShellOutcome::BackgroundLookupError(error) => Some(serde_json::json!({
157 "jobId": error.job_id,
158 "errorCode": error.code,
159 "reason": error.reason,
160 })),
161 }
162 }
163
164 pub fn is_background_status(&self) -> bool {
167 matches!(
168 self,
169 ShellOutcome::Background(BackgroundShellOutcome {
170 display: Some(_),
171 ..
172 })
173 )
174 }
175
176 pub fn is_terminal_background_status(&self) -> bool {
177 matches!(
178 self,
179 ShellOutcome::Background(BackgroundShellOutcome {
180 state: BackgroundJobState::Completed
181 | BackgroundJobState::Failed
182 | BackgroundJobState::Cancelled,
183 display: Some(_),
184 ..
185 })
186 )
187 }
188}
189
190pub struct ToolOutput {
192 pub text: String,
193 pub original_tokens: usize,
194 pub saved_tokens: usize,
195 pub mode: Option<String>,
196 pub path: Option<String>,
198 pub changed: bool,
201 pub shell_outcome: Option<ShellOutcome>,
205 pub content_blocks: Option<Vec<ContentBlock>>,
208}
209
210impl ToolOutput {
211 pub fn simple(text: String) -> Self {
212 Self {
213 text,
214 original_tokens: 0,
215 saved_tokens: 0,
216 mode: None,
217 path: None,
218 changed: false,
219 shell_outcome: None,
220 content_blocks: None,
221 }
222 }
223
224 pub fn to_header_line(&self, tool_name: &str) -> String {
226 let path_str = self.path.as_deref().unwrap_or("—");
227 let mode_str = self.mode.as_deref().unwrap_or("—");
228 let sent = self.original_tokens.saturating_sub(self.saved_tokens);
229 let pct = if self.original_tokens > 0 {
230 (self.saved_tokens as f64 / self.original_tokens as f64 * 100.0) as u32
231 } else {
232 0
233 };
234 format!("[{tool_name}: {path_str}, mode={mode_str}, {sent} tok sent, -{pct}%]")
235 }
236
237 pub fn with_savings(text: String, original: usize, saved: usize) -> Self {
238 Self {
239 text,
240 original_tokens: original,
241 saved_tokens: saved,
242 mode: None,
243 path: None,
244 changed: false,
245 shell_outcome: None,
246 content_blocks: None,
247 }
248 }
249
250 pub fn image(blocks: Vec<ContentBlock>, path: String) -> Self {
253 Self {
254 text: String::new(),
255 original_tokens: 0,
256 saved_tokens: 0,
257 mode: Some("image".to_string()),
258 path: Some(path),
259 changed: false,
260 shell_outcome: None,
261 content_blocks: Some(blocks),
262 }
263 }
264}
265
266pub trait McpTool: Send + Sync {
277 fn name(&self) -> &'static str;
279
280 fn tool_def(&self) -> Tool;
283
284 fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
287 -> Result<ToolOutput, ErrorData>;
288
289 fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
297 false
298 }
299
300 fn attests_source_free(&self, _args: Option<&Map<String, Value>>) -> bool {
308 false
309 }
310
311 fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
324 false
325 }
326}
327
328tokio::task_local! {
329 pub static REQUEST_CT: tokio_util::sync::CancellationToken;
337}
338
339#[derive(Clone)]
347pub struct ToolContext {
348 pub project_root: String,
349 pub extra_roots: Vec<String>,
353 pub minimal: bool,
354 pub resolved_paths: std::collections::HashMap<String, String>,
356 pub crp_mode: crate::tools::CrpMode,
358 pub cache: Option<crate::tools::SharedCache>,
360 pub session: Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::session::SessionState>>>,
362 pub tool_calls:
364 Option<std::sync::Arc<tokio::sync::RwLock<Vec<lean_ctx::core::protocol::ToolCallRecord>>>>,
365 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
367 pub workflow:
369 Option<std::sync::Arc<tokio::sync::RwLock<Option<lean_ctx::core::workflow::WorkflowRun>>>>,
370 pub ledger:
372 Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::context_ledger::ContextLedger>>>,
373 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
375 pub client_role: Option<String>,
378 pub shell_access: Option<bool>,
381 pub pipeline_stats:
383 Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::pipeline::PipelineStats>>>,
384 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
386 pub autonomy: Option<std::sync::Arc<lean_ctx::core::autonomy::AutonomyState>>,
388 pub path_errors: std::collections::HashMap<String, String>,
391 pub bm25_cache: Option<lean_ctx::core::bm25_cache::SharedBm25Cache>,
393 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
395 pub cancel: Option<tokio_util::sync::CancellationToken>,
403}
404
405impl Default for ToolContext {
406 fn default() -> Self {
410 Self {
411 project_root: String::new(),
412 extra_roots: Vec::new(),
413 minimal: false,
414 resolved_paths: std::collections::HashMap::new(),
415 crp_mode: crate::tools::CrpMode::Off,
416 cache: None,
417 session: None,
418 tool_calls: None,
419 agent_id: None,
420 workflow: None,
421 ledger: None,
422 client_name: None,
423 client_role: None,
424 shell_access: None,
425 pipeline_stats: None,
426 call_count: None,
427 autonomy: None,
428 path_errors: std::collections::HashMap::new(),
429 bm25_cache: None,
430 progress_sender: None,
431 cancel: None,
432 }
433 }
434}
435
436impl ToolContext {
437 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
438 self.resolved_paths.get(arg).map(String::as_str)
439 }
440
441 pub fn path_error(&self, key: &str) -> Option<&str> {
443 self.path_errors.get(key).map(String::as_str)
444 }
445
446 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
450 lean_ctx::core::path_resolve::resolve_tool_path_with_roots(
451 Some(&self.project_root),
452 None,
453 path,
454 &self.extra_roots,
455 )
456 }
457
458 pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
467 lean_ctx::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
468 }
469}
470
471pub fn require_resolved_path(
476 ctx: &ToolContext,
477 args: &Map<String, Value>,
478 key: &str,
479) -> Result<String, ErrorData> {
480 if let Some(path) = ctx.resolved_path(key) {
481 return Ok(path.to_string());
482 }
483 if let Some(err) = ctx.path_error(key) {
484 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
485 }
486 if let Some(val) = args.get(key)
487 && !val.is_string()
488 {
489 let type_name = match val {
490 Value::Number(_) => "number",
491 Value::Bool(_) => "boolean",
492 Value::Array(_) => "array",
493 Value::Object(_) => "object",
494 Value::Null => "null",
495 Value::String(_) => unreachable!(),
496 };
497 return Err(ErrorData::invalid_params(
498 format!("{key} must be a string, got {type_name}"),
499 None,
500 ));
501 }
502 Err(ErrorData::invalid_params(
503 format!("{key} is required"),
504 None,
505 ))
506}
507
508pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
509 args.get(key).and_then(|v| v.as_str()).map(String::from)
510}
511
512pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
513 args.get(key)
514 .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
515}
516
517pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
524 get_int(args, key).and_then(|n| usize::try_from(n).ok())
525}
526
527pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
528 args.get(key).and_then(serde_json::Value::as_bool)
529}
530
531pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
532 args.get(key).and_then(serde_json::Value::as_f64)
533}
534
535pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
536 args.get(key).and_then(|v| v.as_array()).map(|arr| {
537 arr.iter()
538 .filter_map(|v| v.as_str().map(String::from))
539 .collect()
540 })
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use serde_json::json;
547
548 fn empty_ctx() -> ToolContext {
549 ToolContext::default()
550 }
551
552 #[test]
553 fn require_resolved_path_returns_resolved() {
554 let mut ctx = empty_ctx();
555 ctx.resolved_paths
556 .insert("path".to_string(), "/abs/file.rs".to_string());
557 let args: Map<String, Value> = Map::new();
558 let result = require_resolved_path(&ctx, &args, "path");
559 assert_eq!(result.unwrap(), "/abs/file.rs");
560 }
561
562 #[test]
563 fn require_resolved_path_surfaces_jail_error() {
564 let mut ctx = empty_ctx();
565 ctx.path_errors.insert(
566 "path".to_string(),
567 "path escapes project root /project".to_string(),
568 );
569 let args: Map<String, Value> = Map::new();
570 let result = require_resolved_path(&ctx, &args, "path");
571 assert!(result.is_err());
572 let err = result.unwrap_err();
573 let msg = format!("{err:?}");
574 assert!(msg.contains("escapes project root"), "got: {msg}");
575 }
576
577 #[test]
578 fn require_resolved_path_detects_non_string() {
579 let ctx = empty_ctx();
580 let mut args: Map<String, Value> = Map::new();
581 args.insert("path".to_string(), json!(42));
582 let result = require_resolved_path(&ctx, &args, "path");
583 assert!(result.is_err());
584 let err = result.unwrap_err();
585 let msg = format!("{err:?}");
586 assert!(msg.contains("must be a string, got number"), "got: {msg}");
587 }
588
589 #[test]
590 fn require_resolved_path_missing_param() {
591 let ctx = empty_ctx();
592 let args: Map<String, Value> = Map::new();
593 let result = require_resolved_path(&ctx, &args, "path");
594 assert!(result.is_err());
595 let err = result.unwrap_err();
596 let msg = format!("{err:?}");
597 assert!(msg.contains("path is required"), "got: {msg}");
598 }
599
600 #[test]
601 fn get_int_coerces_string_to_number() {
602 let mut args = Map::new();
603 args.insert("n".into(), json!("42"));
604 assert_eq!(super::get_int(&args, "n"), Some(42));
605 }
606
607 #[test]
608 fn get_int_native_number_still_works() {
609 let mut args = Map::new();
610 args.insert("n".into(), json!(7));
611 assert_eq!(super::get_int(&args, "n"), Some(7));
612 }
613
614 #[test]
615 fn get_int_invalid_string_returns_none() {
616 let mut args = Map::new();
617 args.insert("n".into(), json!("not_a_number"));
618 assert_eq!(super::get_int(&args, "n"), None);
619 }
620
621 #[test]
622 fn get_usize_coerces_string() {
623 let mut args = Map::new();
624 args.insert("n".into(), json!("100"));
625 assert_eq!(super::get_usize(&args, "n"), Some(100));
626 }
627
628 #[test]
629 fn get_usize_negative_string_returns_none() {
630 let mut args = Map::new();
631 args.insert("n".into(), json!("-5"));
632 assert_eq!(super::get_usize(&args, "n"), None);
633 }
634}