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 rights_name(&self) -> &'static str {
284 self.name()
285 }
286
287 fn tool_def(&self) -> Tool;
290
291 fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
294 -> Result<ToolOutput, ErrorData>;
295
296 fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
304 false
305 }
306
307 fn attests_source_free(&self, _args: Option<&Map<String, Value>>) -> bool {
315 false
316 }
317
318 fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
331 false
332 }
333}
334
335tokio::task_local! {
336 pub static REQUEST_CT: tokio_util::sync::CancellationToken;
344}
345
346#[derive(Clone)]
354pub struct ToolContext {
355 pub project_root: String,
356 pub extra_roots: Vec<String>,
360 pub minimal: bool,
361 pub resolved_paths: std::collections::HashMap<String, String>,
363 pub crp_mode: crate::tools::CrpMode,
365 pub cache: Option<crate::tools::SharedCache>,
367 pub session: Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::session::SessionState>>>,
369 pub tool_calls:
371 Option<std::sync::Arc<tokio::sync::RwLock<Vec<lean_ctx::core::protocol::ToolCallRecord>>>>,
372 pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
374 pub workflow:
376 Option<std::sync::Arc<tokio::sync::RwLock<Option<lean_ctx::core::workflow::WorkflowRun>>>>,
377 pub ledger:
379 Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::context_ledger::ContextLedger>>>,
380 pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
382 pub client_role: Option<String>,
385 pub shell_access: Option<bool>,
388 pub pipeline_stats:
390 Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::pipeline::PipelineStats>>>,
391 pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
393 pub autonomy: Option<std::sync::Arc<lean_ctx::core::autonomy::AutonomyState>>,
395 pub path_errors: std::collections::HashMap<String, String>,
398 pub bm25_cache: Option<lean_ctx::core::bm25_cache::SharedBm25Cache>,
400 pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
402 pub cancel: Option<tokio_util::sync::CancellationToken>,
410}
411
412impl Default for ToolContext {
413 fn default() -> Self {
417 Self {
418 project_root: String::new(),
419 extra_roots: Vec::new(),
420 minimal: false,
421 resolved_paths: std::collections::HashMap::new(),
422 crp_mode: crate::tools::CrpMode::Off,
423 cache: None,
424 session: None,
425 tool_calls: None,
426 agent_id: None,
427 workflow: None,
428 ledger: None,
429 client_name: None,
430 client_role: None,
431 shell_access: None,
432 pipeline_stats: None,
433 call_count: None,
434 autonomy: None,
435 path_errors: std::collections::HashMap::new(),
436 bm25_cache: None,
437 progress_sender: None,
438 cancel: None,
439 }
440 }
441}
442
443impl ToolContext {
444 pub fn resolved_path(&self, arg: &str) -> Option<&str> {
445 self.resolved_paths.get(arg).map(String::as_str)
446 }
447
448 pub fn path_error(&self, key: &str) -> Option<&str> {
450 self.path_errors.get(key).map(String::as_str)
451 }
452
453 pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
457 lean_ctx::core::path_resolve::resolve_tool_path_with_roots(
458 Some(&self.project_root),
459 None,
460 path,
461 &self.extra_roots,
462 )
463 }
464
465 pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
474 lean_ctx::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
475 }
476}
477
478pub fn require_resolved_path(
483 ctx: &ToolContext,
484 args: &Map<String, Value>,
485 key: &str,
486) -> Result<String, ErrorData> {
487 if let Some(path) = ctx.resolved_path(key) {
488 return Ok(path.to_string());
489 }
490 if let Some(err) = ctx.path_error(key) {
491 return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
492 }
493 if let Some(val) = args.get(key)
494 && !val.is_string()
495 {
496 let type_name = match val {
497 Value::Number(_) => "number",
498 Value::Bool(_) => "boolean",
499 Value::Array(_) => "array",
500 Value::Object(_) => "object",
501 Value::Null => "null",
502 Value::String(_) => unreachable!(),
503 };
504 return Err(ErrorData::invalid_params(
505 format!("{key} must be a string, got {type_name}"),
506 None,
507 ));
508 }
509 Err(ErrorData::invalid_params(
510 format!("{key} is required"),
511 None,
512 ))
513}
514
515pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
516 args.get(key).and_then(|v| v.as_str()).map(String::from)
517}
518
519pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
520 args.get(key)
521 .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
522}
523
524pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
531 get_int(args, key).and_then(|n| usize::try_from(n).ok())
532}
533
534pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
535 args.get(key).and_then(serde_json::Value::as_bool)
536}
537
538pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
539 args.get(key).and_then(serde_json::Value::as_f64)
540}
541
542pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
543 args.get(key).and_then(|v| v.as_array()).map(|arr| {
544 arr.iter()
545 .filter_map(|v| v.as_str().map(String::from))
546 .collect()
547 })
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use serde_json::json;
554
555 fn empty_ctx() -> ToolContext {
556 ToolContext::default()
557 }
558
559 #[test]
560 fn require_resolved_path_returns_resolved() {
561 let mut ctx = empty_ctx();
562 ctx.resolved_paths
563 .insert("path".to_string(), "/abs/file.rs".to_string());
564 let args: Map<String, Value> = Map::new();
565 let result = require_resolved_path(&ctx, &args, "path");
566 assert_eq!(result.unwrap(), "/abs/file.rs");
567 }
568
569 #[test]
570 fn require_resolved_path_surfaces_jail_error() {
571 let mut ctx = empty_ctx();
572 ctx.path_errors.insert(
573 "path".to_string(),
574 "path escapes project root /project".to_string(),
575 );
576 let args: Map<String, Value> = Map::new();
577 let result = require_resolved_path(&ctx, &args, "path");
578 assert!(result.is_err());
579 let err = result.unwrap_err();
580 let msg = format!("{err:?}");
581 assert!(msg.contains("escapes project root"), "got: {msg}");
582 }
583
584 #[test]
585 fn require_resolved_path_detects_non_string() {
586 let ctx = empty_ctx();
587 let mut args: Map<String, Value> = Map::new();
588 args.insert("path".to_string(), json!(42));
589 let result = require_resolved_path(&ctx, &args, "path");
590 assert!(result.is_err());
591 let err = result.unwrap_err();
592 let msg = format!("{err:?}");
593 assert!(msg.contains("must be a string, got number"), "got: {msg}");
594 }
595
596 #[test]
597 fn require_resolved_path_missing_param() {
598 let ctx = empty_ctx();
599 let args: Map<String, Value> = Map::new();
600 let result = require_resolved_path(&ctx, &args, "path");
601 assert!(result.is_err());
602 let err = result.unwrap_err();
603 let msg = format!("{err:?}");
604 assert!(msg.contains("path is required"), "got: {msg}");
605 }
606
607 #[test]
608 fn get_int_coerces_string_to_number() {
609 let mut args = Map::new();
610 args.insert("n".into(), json!("42"));
611 assert_eq!(super::get_int(&args, "n"), Some(42));
612 }
613
614 #[test]
615 fn get_int_native_number_still_works() {
616 let mut args = Map::new();
617 args.insert("n".into(), json!(7));
618 assert_eq!(super::get_int(&args, "n"), Some(7));
619 }
620
621 #[test]
622 fn get_int_invalid_string_returns_none() {
623 let mut args = Map::new();
624 args.insert("n".into(), json!("not_a_number"));
625 assert_eq!(super::get_int(&args, "n"), None);
626 }
627
628 #[test]
629 fn get_usize_coerces_string() {
630 let mut args = Map::new();
631 args.insert("n".into(), json!("100"));
632 assert_eq!(super::get_usize(&args, "n"), Some(100));
633 }
634
635 #[test]
636 fn get_usize_negative_string_returns_none() {
637 let mut args = Map::new();
638 args.insert("n".into(), json!("-5"));
639 assert_eq!(super::get_usize(&args, "n"), None);
640 }
641}