1#![cfg_attr(test, allow(clippy::unwrap_used))]
27
28pub mod logging;
29pub mod metrics;
30pub mod otel;
31
32pub use aptu_coder_core::analyze;
33use aptu_coder_core::types::STDIN_MAX_BYTES;
34use aptu_coder_core::{cache, completion, graph, traversal, types};
35
36pub(crate) const EXCLUDED_DIRS: &[&str] = &[
37 "node_modules",
38 "vendor",
39 ".git",
40 "__pycache__",
41 "target",
42 "dist",
43 "build",
44 ".venv",
45];
46
47use aptu_coder_core::cache::{AnalysisCache, CacheTier};
48use aptu_coder_core::formatter::{
49 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
50 format_module_info, format_structure_paginated, format_summary,
51};
52use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
53use aptu_coder_core::pagination::{
54 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
55};
56use aptu_coder_core::traversal::{
57 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
58};
59use aptu_coder_core::types::{
60 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
61 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
62 EditReplaceParams, SymbolMatchMode,
63};
64use logging::LogEvent;
65use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
66use rmcp::handler::server::wrapper::Parameters;
67use rmcp::model::{
68 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
69 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
70 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
71 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
72 SetLevelRequestParams,
73};
74use rmcp::service::{NotificationContext, RequestContext};
75use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
76use serde_json::Value;
77use std::path::{Path, PathBuf};
78use std::sync::{Arc, Mutex};
79use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
80use tracing::{instrument, warn};
81use tracing_subscriber::filter::LevelFilter;
82
83#[cfg(unix)]
84use nix::sys::resource::{Resource, setrlimit};
85
86static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
87
88const SIZE_LIMIT: usize = 50_000;
89
90#[must_use]
93pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
94 summary == Some(true) && cursor.is_some()
95}
96
97pub struct ClientMetadata {
99 pub session_id: Option<String>,
100 pub client_name: Option<String>,
101 pub client_version: Option<String>,
102}
103
104pub fn extract_and_set_trace_context(
112 meta: Option<&rmcp::model::Meta>,
113 client_meta: ClientMetadata,
114) {
115 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
116
117 let span = tracing::Span::current();
118
119 if let Some(sid) = client_meta.session_id {
121 span.record("mcp.session.id", &sid);
122 }
123 if let Some(cn) = client_meta.client_name {
124 span.record("client.name", &cn);
125 }
126 if let Some(cv) = client_meta.client_version {
127 span.record("client.version", &cv);
128 }
129
130 if let Some(asi_str) = meta.and_then(|m| m.0.get("agent-session-id").and_then(|v| v.as_str())) {
132 span.record("mcp.client.session.id", asi_str);
133 }
134
135 let Some(meta) = meta else { return };
136
137 let mut propagation_map = std::collections::HashMap::new();
138
139 if let Some(traceparent) = meta.0.get("traceparent")
141 && let Some(tp_str) = traceparent.as_str()
142 {
143 propagation_map.insert("traceparent".to_string(), tp_str.to_string());
144 }
145
146 if let Some(tracestate) = meta.0.get("tracestate")
148 && let Some(ts_str) = tracestate.as_str()
149 {
150 propagation_map.insert("tracestate".to_string(), ts_str.to_string());
151 }
152
153 if propagation_map.is_empty() {
155 return;
156 }
157
158 let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
160 propagator.extract(&ExtractMap(&propagation_map))
161 });
162
163 let _ = span.set_parent(parent_cx);
166}
167
168struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
170
171impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
172 fn get(&self, key: &str) -> Option<&str> {
173 self.0.get(key).map(|s| s.as_str())
174 }
175
176 fn keys(&self) -> Vec<&str> {
177 self.0.keys().map(|k| k.as_str()).collect()
178 }
179}
180
181#[must_use]
182fn error_meta(
183 category: &'static str,
184 is_retryable: bool,
185 suggested_action: &'static str,
186) -> serde_json::Value {
187 serde_json::json!({
188 "errorCategory": category,
189 "isRetryable": is_retryable,
190 "suggestedAction": suggested_action,
191 })
192}
193
194#[must_use]
195fn err_to_tool_result(e: ErrorData) -> CallToolResult {
196 CallToolResult::error(vec![Content::text(e.message)])
197}
198
199fn err_to_tool_result_from_pagination(
200 e: aptu_coder_core::pagination::PaginationError,
201) -> CallToolResult {
202 let msg = format!("Pagination error: {}", e);
203 CallToolResult::error(vec![Content::text(msg)])
204}
205
206fn no_cache_meta() -> Meta {
207 let mut m = serde_json::Map::new();
208 m.insert(
209 "cache_hint".to_string(),
210 serde_json::Value::String("no-cache".to_string()),
211 );
212 Meta(m)
213}
214
215fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
219 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
221 ErrorData::new(
222 rmcp::model::ErrorCode::INVALID_PARAMS,
223 "path is outside the allowed root".to_string(),
224 Some(error_meta(
225 "validation",
226 false,
227 "ensure the working directory is accessible",
228 )),
229 )
230 })?)
231 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
232
233 let canonical_path = if require_exists {
234 std::fs::canonicalize(path).map_err(|e| {
235 let msg = match e.kind() {
236 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
237 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
238 _ => "path is outside the allowed root".to_string(),
239 };
240 ErrorData::new(
241 rmcp::model::ErrorCode::INVALID_PARAMS,
242 msg,
243 Some(error_meta(
244 "validation",
245 false,
246 "provide a valid path within the working directory",
247 )),
248 )
249 })?
250 } else {
251 let p = std::path::Path::new(path);
253 let mut ancestor = p.to_path_buf();
254 let mut suffix = std::path::PathBuf::new();
255
256 loop {
257 if ancestor.exists() {
258 break;
259 }
260 if let Some(parent) = ancestor.parent() {
261 if let Some(file_name) = ancestor.file_name() {
262 suffix = std::path::PathBuf::from(file_name).join(&suffix);
263 }
264 ancestor = parent.to_path_buf();
265 } else {
266 ancestor = allowed_root.clone();
268 break;
269 }
270 }
271
272 let canonical_base =
273 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
274 canonical_base.join(&suffix)
275 };
276
277 if !canonical_path.starts_with(&allowed_root) {
278 return Err(ErrorData::new(
279 rmcp::model::ErrorCode::INVALID_PARAMS,
280 "path is outside the allowed root".to_string(),
281 Some(error_meta(
282 "validation",
283 false,
284 "provide a path within the current working directory",
285 )),
286 ));
287 }
288
289 Ok(canonical_path)
290}
291
292fn io_error_to_path_error(
294 err: &std::io::Error,
295 path_context: &str,
296 suggested_action: &'static str,
297) -> ErrorData {
298 let msg = match err.kind() {
299 std::io::ErrorKind::NotFound => format!("{path_context} not found"),
300 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
301 _ => format!("{path_context} is invalid"),
302 };
303 let mut meta = error_meta("validation", false, suggested_action);
304 if let Some(obj) = meta.as_object_mut() {
306 obj.insert(
307 "ioErrorKind".to_string(),
308 serde_json::json!(format!("{:?}", err.kind())),
309 );
310 obj.insert(
311 "ioErrorSource".to_string(),
312 serde_json::json!(err.to_string()),
313 );
314 }
315 ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
316}
317
318fn validate_path_in_dir(
322 path: &str,
323 require_exists: bool,
324 working_dir: &std::path::Path,
325) -> Result<std::path::PathBuf, ErrorData> {
326 let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
328 io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
329 })?;
330
331 if !std::fs::metadata(&canonical_working_dir)
333 .map(|m| m.is_dir())
334 .unwrap_or(false)
335 {
336 return Err(ErrorData::new(
337 rmcp::model::ErrorCode::INVALID_PARAMS,
338 "working_dir must be a directory".to_string(),
339 Some(error_meta(
340 "validation",
341 false,
342 "provide a valid directory path",
343 )),
344 ));
345 }
346
347 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
349 ErrorData::new(
350 rmcp::model::ErrorCode::INVALID_PARAMS,
351 "path is outside the allowed root".to_string(),
352 Some(error_meta(
353 "validation",
354 false,
355 "ensure the working directory is accessible",
356 )),
357 )
358 })?)
359 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
360
361 if !canonical_working_dir.starts_with(&allowed_root) {
362 return Err(ErrorData::new(
363 rmcp::model::ErrorCode::INVALID_PARAMS,
364 "working_dir is outside the allowed root".to_string(),
365 Some(error_meta(
366 "validation",
367 false,
368 "provide a working directory within the current working directory",
369 )),
370 ));
371 }
372
373 let canonical_path = if require_exists {
375 let target_path = canonical_working_dir.join(path);
376 std::fs::canonicalize(&target_path).map_err(|e| {
377 io_error_to_path_error(
378 &e,
379 path,
380 "provide a valid path within the working directory",
381 )
382 })?
383 } else {
384 let p = std::path::Path::new(path);
386 let mut ancestor = p.to_path_buf();
387 let mut suffix = std::path::PathBuf::new();
388
389 loop {
390 let full_path = canonical_working_dir.join(&ancestor);
391 if full_path.exists() {
392 break;
393 }
394 if let Some(parent) = ancestor.parent() {
395 if let Some(file_name) = ancestor.file_name() {
396 suffix = std::path::PathBuf::from(file_name).join(&suffix);
397 }
398 ancestor = parent.to_path_buf();
399 } else {
400 ancestor = std::path::PathBuf::new();
402 break;
403 }
404 }
405
406 let canonical_base = canonical_working_dir.join(&ancestor);
407 let canonical_base =
408 std::fs::canonicalize(&canonical_base).unwrap_or(canonical_working_dir.clone());
409 canonical_base.join(&suffix)
410 };
411
412 if !canonical_path.starts_with(&canonical_working_dir) {
420 return Err(ErrorData::new(
421 rmcp::model::ErrorCode::INVALID_PARAMS,
422 "path is outside the working directory".to_string(),
423 Some(error_meta(
424 "validation",
425 false,
426 "provide a path within the working directory",
427 )),
428 ));
429 }
430
431 Ok(canonical_path)
432}
433
434fn paginate_focus_chains(
437 chains: &[graph::InternalCallChain],
438 mode: PaginationMode,
439 offset: usize,
440 page_size: usize,
441) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
442 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
443 ErrorData::new(
444 rmcp::model::ErrorCode::INTERNAL_ERROR,
445 e.to_string(),
446 Some(error_meta("transient", true, "retry the request")),
447 )
448 })?;
449
450 if paginated.next_cursor.is_none() && offset == 0 {
451 return Ok((paginated.items, None));
452 }
453
454 let next = if let Some(raw_cursor) = paginated.next_cursor {
455 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
456 ErrorData::new(
457 rmcp::model::ErrorCode::INVALID_PARAMS,
458 e.to_string(),
459 Some(error_meta("validation", false, "invalid cursor format")),
460 )
461 })?;
462 Some(
463 encode_cursor(&CursorData {
464 mode,
465 offset: decoded.offset,
466 })
467 .map_err(|e| {
468 ErrorData::new(
469 rmcp::model::ErrorCode::INVALID_PARAMS,
470 e.to_string(),
471 Some(error_meta("validation", false, "invalid cursor format")),
472 )
473 })?,
474 )
475 } else {
476 None
477 };
478
479 Ok((paginated.items, next))
480}
481
482fn resolve_shell() -> String {
486 if let Ok(shell) = std::env::var("APTU_SHELL") {
487 return shell;
488 }
489 #[cfg(unix)]
490 {
491 if which::which("bash").is_ok() {
492 return "bash".to_string();
493 }
494 "/bin/sh".to_string()
495 }
496 #[cfg(not(unix))]
497 {
498 "cmd".to_string()
499 }
500}
501
502#[derive(Clone)]
507pub struct CodeAnalyzer {
508 #[allow(dead_code)]
516 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
517 cache: AnalysisCache,
518 disk_cache: std::sync::Arc<cache::DiskCache>,
519 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
520 log_level_filter: Arc<Mutex<LevelFilter>>,
521 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
522 metrics_tx: crate::metrics::MetricsSender,
523 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
524 session_id: Arc<TokioMutex<Option<String>>>,
525 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
527 client_name: Arc<TokioMutex<Option<String>>>,
528 client_version: Arc<TokioMutex<Option<String>>>,
529 resolved_path: Arc<Option<String>>,
532}
533
534#[tool_router]
535impl CodeAnalyzer {
536 #[must_use]
537 pub fn list_tools() -> Vec<rmcp::model::Tool> {
538 Self::tool_router().list_all()
539 }
540
541 pub fn new(
542 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
543 log_level_filter: Arc<Mutex<LevelFilter>>,
544 event_rx: mpsc::UnboundedReceiver<LogEvent>,
545 metrics_tx: crate::metrics::MetricsSender,
546 ) -> Self {
547 let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
548 .ok()
549 .and_then(|v| v.parse().ok())
550 .unwrap_or(100);
551
552 let xdg_data_home = if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
554 && !xdg_data_home.is_empty()
555 {
556 std::path::PathBuf::from(xdg_data_home)
557 } else if let Ok(home) = std::env::var("HOME") {
558 std::path::PathBuf::from(home).join(".local").join("share")
559 } else {
560 std::path::PathBuf::from(".")
561 };
562 let disk_cache_disabled = std::env::var("APTU_CODER_DISK_CACHE_DISABLED")
563 .map(|v| v == "1")
564 .unwrap_or(false);
565 let disk_cache_dir = std::env::var("APTU_CODER_DISK_CACHE_DIR")
566 .map(std::path::PathBuf::from)
567 .unwrap_or_else(|_| xdg_data_home.join("aptu-coder").join("analysis-cache"));
568 let disk_cache =
569 std::sync::Arc::new(cache::DiskCache::new(disk_cache_dir, disk_cache_disabled));
570
571 let resolved_path = {
580 let snapshot_shell = std::env::var("SHELL")
581 .ok()
582 .filter(|s| !s.is_empty())
583 .unwrap_or_else(|| {
584 let s = resolve_shell();
585 if s.is_empty() {
586 "/bin/sh".to_string()
587 } else {
588 s
589 }
590 });
591 let login_path = match std::process::Command::new(&snapshot_shell)
592 .args(["-l", "-c", "echo $PATH"])
593 .output()
594 {
595 Ok(output) => {
596 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
597 if path_str.is_empty() {
598 tracing::warn!(
599 shell = %snapshot_shell,
600 "login shell PATH snapshot returned empty string"
601 );
602 None
603 } else {
604 Some(path_str)
605 }
606 }
607 Err(e) => {
608 tracing::warn!(
609 shell = %snapshot_shell,
610 error = %e,
611 "failed to snapshot login shell PATH"
612 );
613 None
614 }
615 };
616 let path = login_path.or_else(|| std::env::var("PATH").ok());
618 Arc::new(path)
619 };
620
621 CodeAnalyzer {
622 tool_router: Arc::new(RwLock::new(Self::tool_router())),
623 cache: AnalysisCache::new(file_cap),
624 disk_cache,
625 peer,
626 log_level_filter,
627 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
628 metrics_tx,
629 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
630 session_id: Arc::new(TokioMutex::new(None)),
631 profile_meta: Arc::new(TokioMutex::new(None)),
632 client_name: Arc::new(TokioMutex::new(None)),
633 client_version: Arc::new(TokioMutex::new(None)),
634 resolved_path,
635 }
636 }
637
638 #[instrument(skip(self))]
639 async fn emit_progress(
640 &self,
641 peer: Option<Peer<RoleServer>>,
642 token: &ProgressToken,
643 progress: f64,
644 total: f64,
645 message: String,
646 ) {
647 if let Some(peer) = peer {
648 let notification = ServerNotification::ProgressNotification(Notification::new(
649 ProgressNotificationParam {
650 progress_token: token.clone(),
651 progress,
652 total: Some(total),
653 message: Some(message),
654 },
655 ));
656 if let Err(e) = peer.send_notification(notification).await {
657 warn!("Failed to send progress notification: {}", e);
658 }
659 }
660 }
661
662 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
668 async fn handle_overview_mode(
669 &self,
670 params: &AnalyzeDirectoryParams,
671 ct: tokio_util::sync::CancellationToken,
672 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
673 let path = Path::new(¶ms.path);
674 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
675 let counter_clone = counter.clone();
676 let path_owned = path.to_path_buf();
677 let max_depth = params.max_depth;
678 let ct_clone = ct.clone();
679
680 let all_entries = walk_directory(path, None).map_err(|e| {
682 ErrorData::new(
683 rmcp::model::ErrorCode::INTERNAL_ERROR,
684 format!("Failed to walk directory: {e}"),
685 Some(error_meta(
686 "resource",
687 false,
688 "check path permissions and availability",
689 )),
690 )
691 })?;
692
693 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
695
696 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
699 let cache_key = cache::DirectoryCacheKey::from_entries(
700 &all_entries,
701 canonical_max_depth,
702 AnalysisMode::Overview,
703 git_ref_val,
704 );
705
706 if let Some(cached) = self.cache.get_directory(&cache_key) {
708 tracing::debug!(cache_hit = true, message = "returning cached result");
709 return Ok((cached, CacheTier::L1Memory));
710 }
711
712 let root = std::path::Path::new(¶ms.path);
714 let disk_key = {
715 let mut hasher = blake3::Hasher::new();
716 let mut sorted_entries: Vec<_> = all_entries.iter().collect();
717 sorted_entries.sort_by(|a, b| a.path.cmp(&b.path));
718 for entry in &sorted_entries {
719 let rel = entry.path.strip_prefix(root).unwrap_or(&entry.path);
720 hasher.update(rel.as_os_str().to_string_lossy().as_bytes());
721 let mtime_secs = entry
722 .mtime
723 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
724 .map(|d| d.as_secs())
725 .unwrap_or(0);
726 hasher.update(&mtime_secs.to_le_bytes());
727 }
728 if let Some(depth) = canonical_max_depth {
729 hasher.update(depth.to_string().as_bytes());
730 }
731 if let Some(ref git_ref) = params.git_ref {
732 hasher.update(git_ref.as_bytes());
733 }
734 hasher.finalize()
735 };
736
737 if let Some(cached) = self
739 .disk_cache
740 .get::<analyze::AnalysisOutput>("analyze_directory", &disk_key)
741 {
742 let arc = std::sync::Arc::new(cached);
743 self.cache.put_directory(cache_key.clone(), arc.clone());
744 return Ok((arc, CacheTier::L2Disk));
745 }
746
747 let all_entries = if let Some(ref git_ref) = params.git_ref
749 && !git_ref.is_empty()
750 {
751 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
752 ErrorData::new(
753 rmcp::model::ErrorCode::INVALID_PARAMS,
754 format!("git_ref filter failed: {e}"),
755 Some(error_meta(
756 "resource",
757 false,
758 "ensure git is installed and path is inside a git repository",
759 )),
760 )
761 })?;
762 filter_entries_by_git_ref(all_entries, &changed, path)
763 } else {
764 all_entries
765 };
766
767 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
769 Some(traversal::subtree_counts_from_entries(path, &all_entries))
770 } else {
771 None
772 };
773
774 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
776 && depth > 0
777 {
778 all_entries
779 .into_iter()
780 .filter(|e| e.depth <= depth as usize)
781 .collect()
782 } else {
783 all_entries
784 };
785
786 let total_files = entries.iter().filter(|e| !e.is_dir).count();
788
789 let handle = tokio::task::spawn_blocking(move || {
791 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
792 });
793
794 let token = ProgressToken(NumberOrString::String(
796 format!(
797 "analyze-overview-{}",
798 std::time::SystemTime::now()
799 .duration_since(std::time::UNIX_EPOCH)
800 .map(|d| d.as_nanos())
801 .unwrap_or(0)
802 )
803 .into(),
804 ));
805 let peer = self.peer.lock().await.clone();
806 let mut last_progress = 0usize;
807 let mut cancelled = false;
808 loop {
809 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
810 if ct.is_cancelled() {
811 cancelled = true;
812 break;
813 }
814 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
815 if current != last_progress && total_files > 0 {
816 self.emit_progress(
817 peer.clone(),
818 &token,
819 current as f64,
820 total_files as f64,
821 format!("Analyzing {current}/{total_files} files"),
822 )
823 .await;
824 last_progress = current;
825 }
826 if handle.is_finished() {
827 break;
828 }
829 }
830
831 if !cancelled && total_files > 0 {
833 self.emit_progress(
834 peer.clone(),
835 &token,
836 total_files as f64,
837 total_files as f64,
838 format!("Completed analyzing {total_files} files"),
839 )
840 .await;
841 }
842
843 match handle.await {
844 Ok(Ok(mut output)) => {
845 output.subtree_counts = subtree_counts;
846 let arc_output = std::sync::Arc::new(output);
847 self.cache.put_directory(cache_key, arc_output.clone());
848 {
850 let dc = self.disk_cache.clone();
851 let k = disk_key;
852 let v = arc_output.as_ref().clone();
853 let handle = tokio::task::spawn_blocking(move || {
854 dc.put("analyze_directory", &k, &v);
855 dc.drain_write_failures()
856 });
857 let metrics_tx = self.metrics_tx.clone();
858 let sid = self.session_id.lock().await.clone();
859 tokio::spawn(async move {
860 if let Ok(failures) = handle.await
861 && failures > 0
862 {
863 tracing::warn!(
864 tool = "analyze_directory",
865 failures,
866 "L2 disk cache write failed"
867 );
868 metrics_tx.send(crate::metrics::MetricEvent {
869 ts: crate::metrics::unix_ms(),
870 tool: "analyze_directory",
871 duration_ms: 0,
872 output_chars: 0,
873 param_path_depth: 0,
874 max_depth: None,
875 result: "ok",
876 error_type: None,
877 session_id: sid,
878 seq: None,
879 cache_hit: None,
880 cache_write_failure: Some(true),
881 cache_tier: None,
882 exit_code: None,
883 timed_out: false,
884 });
885 }
886 });
887 }
888 Ok((arc_output, CacheTier::Miss))
889 }
890 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
891 rmcp::model::ErrorCode::INTERNAL_ERROR,
892 "Analysis cancelled".to_string(),
893 Some(error_meta("transient", true, "analysis was cancelled")),
894 )),
895 Ok(Err(e)) => Err(ErrorData::new(
896 rmcp::model::ErrorCode::INTERNAL_ERROR,
897 format!("Error analyzing directory: {e}"),
898 Some(error_meta(
899 "resource",
900 false,
901 "check path and file permissions",
902 )),
903 )),
904 Err(e) => Err(ErrorData::new(
905 rmcp::model::ErrorCode::INTERNAL_ERROR,
906 format!("Task join error: {e}"),
907 Some(error_meta("transient", true, "retry the request")),
908 )),
909 }
910 }
911
912 #[instrument(skip(self, params))]
915 async fn handle_file_details_mode(
916 &self,
917 params: &AnalyzeFileParams,
918 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
919 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
921 meta.modified().ok().map(|mtime| cache::CacheKey {
922 path: std::path::PathBuf::from(¶ms.path),
923 modified: mtime,
924 mode: AnalysisMode::FileDetails,
925 })
926 });
927
928 if let Some(ref key) = cache_key
930 && let Some(cached) = self.cache.get(key)
931 {
932 tracing::debug!(cache_hit = true, message = "returning cached result");
933 return Ok((cached, CacheTier::L1Memory));
934 }
935
936 let file_bytes = std::fs::read(¶ms.path).unwrap_or_default();
938 let disk_key = blake3::hash(&file_bytes);
939
940 if let Some(cached) = self
942 .disk_cache
943 .get::<analyze::FileAnalysisOutput>("analyze_file", &disk_key)
944 {
945 let arc = std::sync::Arc::new(cached);
946 if let Some(ref key) = cache_key {
947 self.cache.put(key.clone(), arc.clone());
948 }
949 return Ok((arc, CacheTier::L2Disk));
950 }
951
952 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
954 Ok(output) => {
955 let arc_output = std::sync::Arc::new(output);
956 if let Some(key) = cache_key {
957 self.cache.put(key, arc_output.clone());
958 }
959 {
961 let dc = self.disk_cache.clone();
962 let k = disk_key;
963 let v = arc_output.as_ref().clone();
964 let handle = tokio::task::spawn_blocking(move || {
965 dc.put("analyze_file", &k, &v);
966 dc.drain_write_failures()
967 });
968 let metrics_tx = self.metrics_tx.clone();
969 let sid = self.session_id.lock().await.clone();
970 tokio::spawn(async move {
971 if let Ok(failures) = handle.await
972 && failures > 0
973 {
974 tracing::warn!(
975 tool = "analyze_file",
976 failures,
977 "L2 disk cache write failed"
978 );
979 metrics_tx.send(crate::metrics::MetricEvent {
980 ts: crate::metrics::unix_ms(),
981 tool: "analyze_file",
982 duration_ms: 0,
983 output_chars: 0,
984 param_path_depth: 0,
985 max_depth: None,
986 result: "ok",
987 error_type: None,
988 session_id: sid,
989 seq: None,
990 cache_hit: None,
991 cache_write_failure: Some(true),
992 cache_tier: None,
993 exit_code: None,
994 timed_out: false,
995 });
996 }
997 });
998 }
999 Ok((arc_output, CacheTier::Miss))
1000 }
1001 Err(e) => Err(ErrorData::new(
1002 rmcp::model::ErrorCode::INTERNAL_ERROR,
1003 format!("Error analyzing file: {e}"),
1004 Some(error_meta(
1005 "resource",
1006 false,
1007 "check file path and permissions",
1008 )),
1009 )),
1010 }
1011 }
1012
1013 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
1015 let has_rust = entries.iter().any(|e| {
1016 !e.is_dir
1017 && e.path
1018 .extension()
1019 .and_then(|x: &std::ffi::OsStr| x.to_str())
1020 == Some("rs")
1021 });
1022
1023 if !has_rust {
1024 return Err(ErrorData::new(
1025 rmcp::model::ErrorCode::INVALID_PARAMS,
1026 "impl_only=true requires Rust source files. No .rs files found in the given path. Use analyze_symbol without impl_only for cross-language analysis.".to_string(),
1027 Some(error_meta(
1028 "validation",
1029 false,
1030 "remove impl_only or point to a directory containing .rs files",
1031 )),
1032 ));
1033 }
1034 Ok(())
1035 }
1036
1037 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
1039 if import_lookup == Some(true) && symbol.is_empty() {
1040 return Err(ErrorData::new(
1041 rmcp::model::ErrorCode::INVALID_PARAMS,
1042 "import_lookup=true requires symbol to contain the module path to search for"
1043 .to_string(),
1044 Some(error_meta(
1045 "validation",
1046 false,
1047 "set symbol to the module path when using import_lookup=true",
1048 )),
1049 ));
1050 }
1051 Ok(())
1052 }
1053
1054 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
1057 &self,
1058 analysis_params: &FocusedAnalysisParams,
1059 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1060 ct: tokio_util::sync::CancellationToken,
1061 entries: std::sync::Arc<Vec<WalkEntry>>,
1062 total_files: usize,
1063 symbol_display: &str,
1064 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1065 let counter_clone = counter.clone();
1066 let ct_clone = ct.clone();
1067 let entries_clone = std::sync::Arc::clone(&entries);
1068 let path_owned = analysis_params.path.clone();
1069 let symbol_owned = analysis_params.symbol.clone();
1070 let match_mode_owned = analysis_params.match_mode.clone();
1071 let follow_depth = analysis_params.follow_depth;
1072 let max_depth = analysis_params.max_depth;
1073 let ast_recursion_limit = analysis_params.ast_recursion_limit;
1074 let use_summary = analysis_params.use_summary;
1075 let impl_only = analysis_params.impl_only;
1076 let def_use = analysis_params.def_use;
1077 let parse_timeout_micros = analysis_params.parse_timeout_micros;
1078 let handle = tokio::task::spawn_blocking(move || {
1079 let params = analyze::FocusedAnalysisConfig {
1080 focus: symbol_owned,
1081 match_mode: match_mode_owned,
1082 follow_depth,
1083 max_depth,
1084 ast_recursion_limit,
1085 use_summary,
1086 impl_only,
1087 def_use,
1088 parse_timeout_micros,
1089 };
1090 analyze::analyze_focused_with_progress_with_entries(
1091 &path_owned,
1092 ¶ms,
1093 &counter_clone,
1094 &ct_clone,
1095 &entries_clone,
1096 )
1097 });
1098
1099 let token = ProgressToken(NumberOrString::String(
1100 format!(
1101 "analyze-symbol-{}",
1102 std::time::SystemTime::now()
1103 .duration_since(std::time::UNIX_EPOCH)
1104 .map(|d| d.as_nanos())
1105 .unwrap_or(0)
1106 )
1107 .into(),
1108 ));
1109 let peer = self.peer.lock().await.clone();
1110 let mut last_progress = 0usize;
1111 let mut cancelled = false;
1112
1113 loop {
1114 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1115 if ct.is_cancelled() {
1116 cancelled = true;
1117 break;
1118 }
1119 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
1120 if current != last_progress && total_files > 0 {
1121 self.emit_progress(
1122 peer.clone(),
1123 &token,
1124 current as f64,
1125 total_files as f64,
1126 format!(
1127 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
1128 ),
1129 )
1130 .await;
1131 last_progress = current;
1132 }
1133 if handle.is_finished() {
1134 break;
1135 }
1136 }
1137
1138 if !cancelled && total_files > 0 {
1139 self.emit_progress(
1140 peer.clone(),
1141 &token,
1142 total_files as f64,
1143 total_files as f64,
1144 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
1145 )
1146 .await;
1147 }
1148
1149 match handle.await {
1150 Ok(Ok(output)) => Ok(output),
1151 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
1152 rmcp::model::ErrorCode::INTERNAL_ERROR,
1153 "Analysis cancelled".to_string(),
1154 Some(error_meta("transient", true, "analysis was cancelled")),
1155 )),
1156 Ok(Err(e)) => Err(ErrorData::new(
1157 rmcp::model::ErrorCode::INTERNAL_ERROR,
1158 format!("Error analyzing symbol: {e}"),
1159 Some(error_meta("resource", false, "check symbol name and file")),
1160 )),
1161 Err(e) => Err(ErrorData::new(
1162 rmcp::model::ErrorCode::INTERNAL_ERROR,
1163 format!("Task join error: {e}"),
1164 Some(error_meta("transient", true, "retry the request")),
1165 )),
1166 }
1167 }
1168
1169 async fn run_focused_with_auto_summary(
1171 &self,
1172 params: &AnalyzeSymbolParams,
1173 analysis_params: &FocusedAnalysisParams,
1174 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1175 ct: tokio_util::sync::CancellationToken,
1176 entries: std::sync::Arc<Vec<WalkEntry>>,
1177 total_files: usize,
1178 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1179 let use_summary_for_task = params.output_control.force != Some(true)
1180 && params.output_control.summary == Some(true);
1181
1182 let analysis_params_initial = FocusedAnalysisParams {
1183 use_summary: use_summary_for_task,
1184 ..analysis_params.clone()
1185 };
1186
1187 let mut output = self
1188 .poll_progress_until_done(
1189 &analysis_params_initial,
1190 counter.clone(),
1191 ct.clone(),
1192 entries.clone(),
1193 total_files,
1194 ¶ms.symbol,
1195 )
1196 .await?;
1197
1198 if params.output_control.summary.is_none()
1199 && params.output_control.force != Some(true)
1200 && output.formatted.len() > SIZE_LIMIT
1201 {
1202 tracing::debug!(
1203 auto_summary = true,
1204 message = "output exceeded size limit, retrying with summary"
1205 );
1206 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1207 let analysis_params_retry = FocusedAnalysisParams {
1208 use_summary: true,
1209 ..analysis_params.clone()
1210 };
1211 let summary_result = self
1212 .poll_progress_until_done(
1213 &analysis_params_retry,
1214 counter2,
1215 ct,
1216 entries,
1217 total_files,
1218 ¶ms.symbol,
1219 )
1220 .await;
1221
1222 if let Ok(summary_output) = summary_result {
1223 output.formatted = summary_output.formatted;
1224 } else {
1225 let estimated_tokens = output.formatted.len() / 4;
1226 let message = format!(
1227 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
1228 output.formatted.len(),
1229 estimated_tokens
1230 );
1231 return Err(ErrorData::new(
1232 rmcp::model::ErrorCode::INVALID_PARAMS,
1233 message,
1234 Some(error_meta(
1235 "validation",
1236 false,
1237 "use summary=true or force=true",
1238 )),
1239 ));
1240 }
1241 } else if output.formatted.len() > SIZE_LIMIT
1242 && params.output_control.force != Some(true)
1243 && params.output_control.summary == Some(false)
1244 {
1245 let estimated_tokens = output.formatted.len() / 4;
1246 let message = format!(
1247 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1248 - force=true to return full output\n\
1249 - summary=true to get compact summary\n\
1250 - Narrow your scope (smaller directory, specific file)",
1251 output.formatted.len(),
1252 estimated_tokens
1253 );
1254 return Err(ErrorData::new(
1255 rmcp::model::ErrorCode::INVALID_PARAMS,
1256 message,
1257 Some(error_meta(
1258 "validation",
1259 false,
1260 "use force=true, summary=true, or narrow scope",
1261 )),
1262 ));
1263 }
1264
1265 Ok(output)
1266 }
1267
1268 #[instrument(skip(self, params, ct))]
1272 async fn handle_focused_mode(
1273 &self,
1274 params: &AnalyzeSymbolParams,
1275 ct: tokio_util::sync::CancellationToken,
1276 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1277 let path = Path::new(¶ms.path);
1278 let raw_entries = match walk_directory(path, params.max_depth) {
1279 Ok(e) => e,
1280 Err(e) => {
1281 return Err(ErrorData::new(
1282 rmcp::model::ErrorCode::INTERNAL_ERROR,
1283 format!("Failed to walk directory: {e}"),
1284 Some(error_meta(
1285 "resource",
1286 false,
1287 "check path permissions and availability",
1288 )),
1289 ));
1290 }
1291 };
1292 let filtered_entries = if let Some(ref git_ref) = params.git_ref
1294 && !git_ref.is_empty()
1295 {
1296 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1297 ErrorData::new(
1298 rmcp::model::ErrorCode::INVALID_PARAMS,
1299 format!("git_ref filter failed: {e}"),
1300 Some(error_meta(
1301 "resource",
1302 false,
1303 "ensure git is installed and path is inside a git repository",
1304 )),
1305 )
1306 })?;
1307 filter_entries_by_git_ref(raw_entries, &changed, path)
1308 } else {
1309 raw_entries
1310 };
1311 let entries = std::sync::Arc::new(filtered_entries);
1312
1313 if params.impl_only == Some(true) {
1314 Self::validate_impl_only(&entries)?;
1315 }
1316
1317 let total_files = entries.iter().filter(|e| !e.is_dir).count();
1318 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1319
1320 let analysis_params = FocusedAnalysisParams {
1321 path: path.to_path_buf(),
1322 symbol: params.symbol.clone(),
1323 match_mode: params.match_mode.clone().unwrap_or_default(),
1324 follow_depth: params.follow_depth.unwrap_or(1),
1325 max_depth: params.max_depth,
1326 ast_recursion_limit: params.ast_recursion_limit,
1327 use_summary: false,
1328 impl_only: params.impl_only,
1329 def_use: params.def_use.unwrap_or(false),
1330 parse_timeout_micros: None,
1331 };
1332
1333 let mut output = self
1334 .run_focused_with_auto_summary(
1335 params,
1336 &analysis_params,
1337 counter,
1338 ct,
1339 entries,
1340 total_files,
1341 )
1342 .await?;
1343
1344 if params.impl_only == Some(true) {
1345 let filter_line = format!(
1346 "FILTER: impl_only=true ({} of {} callers shown)\n",
1347 output.impl_trait_caller_count, output.unfiltered_caller_count
1348 );
1349 output.formatted = format!("{}{}", filter_line, output.formatted);
1350
1351 if output.impl_trait_caller_count == 0 {
1352 output.formatted.push_str(
1353 "\nNOTE: No impl-trait callers found. The symbol may be a plain function or struct, not a trait method. Remove impl_only to see all callers.\n"
1354 );
1355 }
1356 }
1357
1358 Ok(output)
1359 }
1360
1361 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1362 #[tool(
1363 name = "analyze_directory",
1364 title = "Analyze Directory",
1365 description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Returns per-file stats plus next_cursor for pagination. Fails if summary=true and cursor. For 1000+ files, use max_depth=2-3 and summary=true. git_ref restricts to files changed since a branch/tag/commit. Empty directories return zero counts. Example queries: Analyze the src/ directory to understand module structure; What files are in the tests/ directory and how large are they?",
1366 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1367 annotations(
1368 title = "Analyze Directory",
1369 read_only_hint = true,
1370 destructive_hint = false,
1371 idempotent_hint = true,
1372 open_world_hint = false
1373 )
1374 )]
1375 async fn analyze_directory(
1376 &self,
1377 params: Parameters<AnalyzeDirectoryParams>,
1378 context: RequestContext<RoleServer>,
1379 ) -> Result<CallToolResult, ErrorData> {
1380 let params = params.0;
1381 let session_id = self.session_id.lock().await.clone();
1383 let client_name = self.client_name.lock().await.clone();
1384 let client_version = self.client_version.lock().await.clone();
1385 extract_and_set_trace_context(
1386 Some(&context.meta),
1387 ClientMetadata {
1388 session_id,
1389 client_name,
1390 client_version,
1391 },
1392 );
1393 let span = tracing::Span::current();
1394 span.record("gen_ai.system", "mcp");
1395 span.record("gen_ai.operation.name", "execute_tool");
1396 span.record("gen_ai.tool.name", "analyze_directory");
1397 span.record("path", ¶ms.path);
1398 let _validated_path = match validate_path(¶ms.path, true) {
1399 Ok(p) => p,
1400 Err(e) => {
1401 span.record("error", true);
1402 span.record("error.type", "invalid_params");
1403 return Ok(err_to_tool_result(e));
1404 }
1405 };
1406 let ct = context.ct.clone();
1407 let t_start = std::time::Instant::now();
1408 let param_path = params.path.clone();
1409 let max_depth_val = params.max_depth;
1410 let seq = self
1411 .session_call_seq
1412 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1413 let sid = self.session_id.lock().await.clone();
1414
1415 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
1417 Ok(v) => v,
1418 Err(e) => {
1419 span.record("error", true);
1420 span.record("error.type", "internal_error");
1421 return Ok(err_to_tool_result(e));
1422 }
1423 };
1424 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1427 Ok(owned) => owned,
1428 Err(arc) => (*arc).clone(),
1429 };
1430
1431 if summary_cursor_conflict(
1434 params.output_control.summary,
1435 params.pagination.cursor.as_deref(),
1436 ) {
1437 span.record("error", true);
1438 span.record("error.type", "invalid_params");
1439 return Ok(err_to_tool_result(ErrorData::new(
1440 rmcp::model::ErrorCode::INVALID_PARAMS,
1441 "summary=true is incompatible with a pagination cursor; use one or the other"
1442 .to_string(),
1443 Some(error_meta(
1444 "validation",
1445 false,
1446 "remove cursor or set summary=false",
1447 )),
1448 )));
1449 }
1450
1451 let use_summary = if params.output_control.force == Some(true) {
1453 false
1454 } else if params.output_control.summary == Some(true) {
1455 true
1456 } else if params.output_control.summary == Some(false) {
1457 false
1458 } else {
1459 output.formatted.len() > SIZE_LIMIT
1460 };
1461
1462 if use_summary {
1463 output.formatted = format_summary(
1464 &output.entries,
1465 &output.files,
1466 params.max_depth,
1467 output.subtree_counts.as_deref(),
1468 );
1469 }
1470
1471 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1473 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1474 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1475 ErrorData::new(
1476 rmcp::model::ErrorCode::INVALID_PARAMS,
1477 e.to_string(),
1478 Some(error_meta("validation", false, "invalid cursor format")),
1479 )
1480 }) {
1481 Ok(v) => v,
1482 Err(e) => {
1483 span.record("error", true);
1484 span.record("error.type", "invalid_params");
1485 return Ok(err_to_tool_result(e));
1486 }
1487 };
1488 cursor_data.offset
1489 } else {
1490 0
1491 };
1492
1493 let paginated =
1495 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1496 Ok(v) => v,
1497 Err(e) => {
1498 span.record("error", true);
1499 span.record("error.type", "internal_error");
1500 return Ok(err_to_tool_result(ErrorData::new(
1501 rmcp::model::ErrorCode::INTERNAL_ERROR,
1502 e.to_string(),
1503 Some(error_meta("transient", true, "retry the request")),
1504 )));
1505 }
1506 };
1507
1508 let verbose = params.output_control.verbose.unwrap_or(false);
1509 if !use_summary {
1510 output.formatted = format_structure_paginated(
1511 &paginated.items,
1512 paginated.total,
1513 params.max_depth,
1514 Some(Path::new(¶ms.path)),
1515 verbose,
1516 );
1517 }
1518
1519 if use_summary {
1521 output.next_cursor = None;
1522 } else {
1523 output.next_cursor.clone_from(&paginated.next_cursor);
1524 }
1525
1526 let mut final_text = output.formatted.clone();
1528 if !use_summary && let Some(cursor) = paginated.next_cursor {
1529 final_text.push('\n');
1530 final_text.push_str("NEXT_CURSOR: ");
1531 final_text.push_str(&cursor);
1532 }
1533
1534 tracing::Span::current().record("cache_tier", dir_cache_hit.as_str());
1536
1537 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1539 let mut meta = no_cache_meta().0;
1540 meta.insert(
1541 "content_hash".to_string(),
1542 serde_json::Value::String(content_hash),
1543 );
1544 let meta = rmcp::model::Meta(meta);
1545
1546 let mut result =
1547 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1548 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1549 result.structured_content = Some(structured);
1550 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1551 self.metrics_tx.send(crate::metrics::MetricEvent {
1552 ts: crate::metrics::unix_ms(),
1553 tool: "analyze_directory",
1554 duration_ms: dur,
1555 output_chars: final_text.len(),
1556 param_path_depth: crate::metrics::path_component_count(¶m_path),
1557 max_depth: max_depth_val,
1558 result: "ok",
1559 error_type: None,
1560 session_id: sid,
1561 seq: Some(seq),
1562 cache_hit: Some(dir_cache_hit != CacheTier::Miss),
1563 cache_write_failure: None,
1564 cache_tier: Some(dir_cache_hit.as_str()),
1565 exit_code: None,
1566 timed_out: false,
1567 });
1568 Ok(result)
1569 }
1570
1571 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1572 #[tool(
1573 name = "analyze_file",
1574 title = "Analyze File",
1575 description = "Functions, types, classes, and imports from a single source file. Returns functions (name, signature, line range), classes (methods, fields, inheritance), imports; paginate with cursor/page_size. Use fields=[\"functions\",\"classes\",\"imports\"] to limit output sections. Fails if directory path supplied; use analyze_directory instead. Fails if summary=true and cursor. git_ref not supported for single-file analysis. Use analyze_module for lightweight function/import index (~75% smaller). Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/lib.rs?; Show me the classes and their methods in src/analyzer.py.",
1576 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1577 annotations(
1578 title = "Analyze File",
1579 read_only_hint = true,
1580 destructive_hint = false,
1581 idempotent_hint = true,
1582 open_world_hint = false
1583 )
1584 )]
1585 async fn analyze_file(
1586 &self,
1587 params: Parameters<AnalyzeFileParams>,
1588 context: RequestContext<RoleServer>,
1589 ) -> Result<CallToolResult, ErrorData> {
1590 let params = params.0;
1591 let session_id = self.session_id.lock().await.clone();
1593 let client_name = self.client_name.lock().await.clone();
1594 let client_version = self.client_version.lock().await.clone();
1595 extract_and_set_trace_context(
1596 Some(&context.meta),
1597 ClientMetadata {
1598 session_id,
1599 client_name,
1600 client_version,
1601 },
1602 );
1603 let span = tracing::Span::current();
1604 span.record("gen_ai.system", "mcp");
1605 span.record("gen_ai.operation.name", "execute_tool");
1606 span.record("gen_ai.tool.name", "analyze_file");
1607 span.record("path", ¶ms.path);
1608 let _validated_path = match validate_path(¶ms.path, true) {
1609 Ok(p) => p,
1610 Err(e) => {
1611 span.record("error", true);
1612 span.record("error.type", "invalid_params");
1613 return Ok(err_to_tool_result(e));
1614 }
1615 };
1616 let t_start = std::time::Instant::now();
1617 let param_path = params.path.clone();
1618 let seq = self
1619 .session_call_seq
1620 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1621 let sid = self.session_id.lock().await.clone();
1622
1623 if std::path::Path::new(¶ms.path).is_dir() {
1625 span.record("error", true);
1626 span.record("error.type", "invalid_params");
1627 return Ok(err_to_tool_result(ErrorData::new(
1628 rmcp::model::ErrorCode::INVALID_PARAMS,
1629 format!(
1630 "'{}' is a directory; use analyze_directory instead",
1631 params.path
1632 ),
1633 Some(error_meta(
1634 "validation",
1635 false,
1636 "pass a file path, not a directory",
1637 )),
1638 )));
1639 }
1640
1641 if summary_cursor_conflict(
1643 params.output_control.summary,
1644 params.pagination.cursor.as_deref(),
1645 ) {
1646 span.record("error", true);
1647 span.record("error.type", "invalid_params");
1648 return Ok(err_to_tool_result(ErrorData::new(
1649 rmcp::model::ErrorCode::INVALID_PARAMS,
1650 "summary=true is incompatible with a pagination cursor; use one or the other"
1651 .to_string(),
1652 Some(error_meta(
1653 "validation",
1654 false,
1655 "remove cursor or set summary=false",
1656 )),
1657 )));
1658 }
1659
1660 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1662 Ok(v) => v,
1663 Err(e) => {
1664 span.record("error", true);
1665 span.record("error.type", "internal_error");
1666 return Ok(err_to_tool_result(e));
1667 }
1668 };
1669
1670 let mut formatted = arc_output.formatted.clone();
1674 let line_count = arc_output.line_count;
1675
1676 let use_summary = if params.output_control.force == Some(true) {
1678 false
1679 } else if params.output_control.summary == Some(true) {
1680 true
1681 } else if params.output_control.summary == Some(false) {
1682 false
1683 } else {
1684 formatted.len() > SIZE_LIMIT
1685 };
1686
1687 if use_summary {
1688 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1689 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1690 span.record("error", true);
1691 span.record("error.type", "invalid_params");
1692 let estimated_tokens = formatted.len() / 4;
1693 let message = format!(
1694 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1695 - force=true to return full output\n\
1696 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1697 - Use summary=true for a compact overview",
1698 formatted.len(),
1699 estimated_tokens
1700 );
1701 return Ok(err_to_tool_result(ErrorData::new(
1702 rmcp::model::ErrorCode::INVALID_PARAMS,
1703 message,
1704 Some(error_meta(
1705 "validation",
1706 false,
1707 "use force=true, fields, or summary=true",
1708 )),
1709 )));
1710 }
1711
1712 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1714 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1715 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1716 ErrorData::new(
1717 rmcp::model::ErrorCode::INVALID_PARAMS,
1718 e.to_string(),
1719 Some(error_meta("validation", false, "invalid cursor format")),
1720 )
1721 }) {
1722 Ok(v) => v,
1723 Err(e) => {
1724 span.record("error", true);
1725 span.record("error.type", "invalid_params");
1726 return Ok(err_to_tool_result(e));
1727 }
1728 };
1729 cursor_data.offset
1730 } else {
1731 0
1732 };
1733
1734 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1736 .semantic
1737 .functions
1738 .iter()
1739 .filter(|func| {
1740 !arc_output
1741 .semantic
1742 .classes
1743 .iter()
1744 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1745 })
1746 .cloned()
1747 .collect();
1748
1749 let paginated =
1751 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1752 Ok(v) => v,
1753 Err(e) => {
1754 return Ok(err_to_tool_result(ErrorData::new(
1755 rmcp::model::ErrorCode::INTERNAL_ERROR,
1756 e.to_string(),
1757 Some(error_meta("transient", true, "retry the request")),
1758 )));
1759 }
1760 };
1761
1762 let verbose = params.output_control.verbose.unwrap_or(false);
1764 if !use_summary {
1765 formatted = format_file_details_paginated(
1767 &paginated.items,
1768 paginated.total,
1769 &arc_output.semantic,
1770 ¶ms.path,
1771 line_count,
1772 offset,
1773 verbose,
1774 params.fields.as_deref(),
1775 );
1776 }
1777
1778 let next_cursor = if use_summary {
1780 None
1781 } else {
1782 paginated.next_cursor.clone()
1783 };
1784
1785 let mut final_text = formatted.clone();
1787 if !use_summary && let Some(ref cursor) = next_cursor {
1788 final_text.push('\n');
1789 final_text.push_str("NEXT_CURSOR: ");
1790 final_text.push_str(cursor);
1791 }
1792
1793 let response_output = analyze::FileAnalysisOutput::new(
1795 formatted,
1796 arc_output.semantic.clone(),
1797 line_count,
1798 next_cursor,
1799 );
1800
1801 tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1803
1804 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1806 let mut meta = no_cache_meta().0;
1807 meta.insert(
1808 "content_hash".to_string(),
1809 serde_json::Value::String(content_hash),
1810 );
1811 let meta = rmcp::model::Meta(meta);
1812
1813 let mut result =
1814 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1815 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1816 result.structured_content = Some(structured);
1817 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1818 self.metrics_tx.send(crate::metrics::MetricEvent {
1819 ts: crate::metrics::unix_ms(),
1820 tool: "analyze_file",
1821 duration_ms: dur,
1822 output_chars: final_text.len(),
1823 param_path_depth: crate::metrics::path_component_count(¶m_path),
1824 max_depth: None,
1825 result: "ok",
1826 error_type: None,
1827 session_id: sid,
1828 seq: Some(seq),
1829 cache_hit: Some(file_cache_hit != CacheTier::Miss),
1830 cache_write_failure: None,
1831 cache_tier: Some(file_cache_hit.as_str()),
1832 exit_code: None,
1833 timed_out: false,
1834 });
1835 Ok(result)
1836 }
1837
1838 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, symbol = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1839 #[tool(
1840 name = "analyze_symbol",
1841 title = "Analyze Symbol",
1842 description = "Use when you need to: find all callers of a function across the codebase, trace transitive call chains, or locate all files importing a module path. Prefer over analyze_file when the question is \"who calls X\" or \"what does X call\" rather than \"what is in this file\".\n\nCall graph for a named symbol across all files in a directory. Returns callers and callees. Modes: call graph (default), import_lookup (files importing a module path), def_use (write/read sites). Fails if file path supplied; fails if impl_only=true on non-Rust directory; fails if import_lookup=true with empty symbol; fails if summary=true and cursor. match_mode controls name matching (exact/insensitive/prefix/contains). git_ref restricts to changed files. Example queries: Find all callers of parse_config; Find all files that import std::collections.",
1843 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1844 annotations(
1845 title = "Analyze Symbol",
1846 read_only_hint = true,
1847 destructive_hint = false,
1848 idempotent_hint = true,
1849 open_world_hint = false
1850 )
1851 )]
1852 async fn analyze_symbol(
1853 &self,
1854 params: Parameters<AnalyzeSymbolParams>,
1855 context: RequestContext<RoleServer>,
1856 ) -> Result<CallToolResult, ErrorData> {
1857 let params = params.0;
1858 let session_id = self.session_id.lock().await.clone();
1860 let client_name = self.client_name.lock().await.clone();
1861 let client_version = self.client_version.lock().await.clone();
1862 extract_and_set_trace_context(
1863 Some(&context.meta),
1864 ClientMetadata {
1865 session_id,
1866 client_name,
1867 client_version,
1868 },
1869 );
1870 let span = tracing::Span::current();
1871 span.record("gen_ai.system", "mcp");
1872 span.record("gen_ai.operation.name", "execute_tool");
1873 span.record("gen_ai.tool.name", "analyze_symbol");
1874 span.record("symbol", ¶ms.symbol);
1875 let _validated_path = match validate_path(¶ms.path, true) {
1876 Ok(p) => p,
1877 Err(e) => {
1878 span.record("error", true);
1879 span.record("error.type", "invalid_params");
1880 return Ok(err_to_tool_result(e));
1881 }
1882 };
1883 let ct = context.ct.clone();
1884 let t_start = std::time::Instant::now();
1885 let param_path = params.path.clone();
1886 let max_depth_val = params.follow_depth;
1887 let seq = self
1888 .session_call_seq
1889 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1890 let sid = self.session_id.lock().await.clone();
1891
1892 if std::path::Path::new(¶ms.path).is_file() {
1894 span.record("error", true);
1895 span.record("error.type", "invalid_params");
1896 return Ok(err_to_tool_result(ErrorData::new(
1897 rmcp::model::ErrorCode::INVALID_PARAMS,
1898 format!(
1899 "'{}' is a file; analyze_symbol requires a directory path",
1900 params.path
1901 ),
1902 Some(error_meta(
1903 "validation",
1904 false,
1905 "pass a directory path, not a file",
1906 )),
1907 )));
1908 }
1909
1910 if summary_cursor_conflict(
1912 params.output_control.summary,
1913 params.pagination.cursor.as_deref(),
1914 ) {
1915 span.record("error", true);
1916 span.record("error.type", "invalid_params");
1917 return Ok(err_to_tool_result(ErrorData::new(
1918 rmcp::model::ErrorCode::INVALID_PARAMS,
1919 "summary=true is incompatible with a pagination cursor; use one or the other"
1920 .to_string(),
1921 Some(error_meta(
1922 "validation",
1923 false,
1924 "remove cursor or set summary=false",
1925 )),
1926 )));
1927 }
1928
1929 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1931 span.record("error", true);
1932 span.record("error.type", "invalid_params");
1933 return Ok(err_to_tool_result(e));
1934 }
1935
1936 if params.import_lookup == Some(true) {
1938 let path_owned = PathBuf::from(¶ms.path);
1939 let symbol = params.symbol.clone();
1940 let git_ref = params.git_ref.clone();
1941 let max_depth = params.max_depth;
1942 let ast_recursion_limit = params.ast_recursion_limit;
1943
1944 let handle = tokio::task::spawn_blocking(move || {
1945 let path = path_owned.as_path();
1946 let raw_entries = match walk_directory(path, max_depth) {
1947 Ok(e) => e,
1948 Err(e) => {
1949 return Err(ErrorData::new(
1950 rmcp::model::ErrorCode::INTERNAL_ERROR,
1951 format!("Failed to walk directory: {e}"),
1952 Some(error_meta(
1953 "resource",
1954 false,
1955 "check path permissions and availability",
1956 )),
1957 ));
1958 }
1959 };
1960 let entries = if let Some(ref git_ref_val) = git_ref
1962 && !git_ref_val.is_empty()
1963 {
1964 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1965 Ok(c) => c,
1966 Err(e) => {
1967 return Err(ErrorData::new(
1968 rmcp::model::ErrorCode::INVALID_PARAMS,
1969 format!("git_ref filter failed: {e}"),
1970 Some(error_meta(
1971 "resource",
1972 false,
1973 "ensure git is installed and path is inside a git repository",
1974 )),
1975 ));
1976 }
1977 };
1978 filter_entries_by_git_ref(raw_entries, &changed, path)
1979 } else {
1980 raw_entries
1981 };
1982 let output = match analyze::analyze_import_lookup(
1983 path,
1984 &symbol,
1985 &entries,
1986 ast_recursion_limit,
1987 ) {
1988 Ok(v) => v,
1989 Err(e) => {
1990 return Err(ErrorData::new(
1991 rmcp::model::ErrorCode::INTERNAL_ERROR,
1992 format!("import_lookup failed: {e}"),
1993 Some(error_meta(
1994 "resource",
1995 false,
1996 "check path and file permissions",
1997 )),
1998 ));
1999 }
2000 };
2001 Ok(output)
2002 });
2003
2004 let output = match handle.await {
2005 Ok(Ok(v)) => v,
2006 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
2007 Err(e) => {
2008 return Ok(err_to_tool_result(ErrorData::new(
2009 rmcp::model::ErrorCode::INTERNAL_ERROR,
2010 format!("spawn_blocking failed: {e}"),
2011 Some(error_meta("resource", false, "internal error")),
2012 )));
2013 }
2014 };
2015
2016 let final_text = output.formatted.clone();
2017
2018 tracing::Span::current().record("cache_tier", "Miss");
2020
2021 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2023 let mut meta = no_cache_meta().0;
2024 meta.insert(
2025 "content_hash".to_string(),
2026 serde_json::Value::String(content_hash),
2027 );
2028
2029 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2030 .with_meta(Some(Meta(meta)));
2031 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2032 result.structured_content = Some(structured);
2033 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2034 self.metrics_tx.send(crate::metrics::MetricEvent {
2035 ts: crate::metrics::unix_ms(),
2036 tool: "analyze_symbol",
2037 duration_ms: dur,
2038 output_chars: final_text.len(),
2039 param_path_depth: crate::metrics::path_component_count(¶m_path),
2040 max_depth: max_depth_val,
2041 result: "ok",
2042 error_type: None,
2043 session_id: sid,
2044 seq: Some(seq),
2045 cache_hit: Some(false),
2046 cache_tier: Some(CacheTier::Miss.as_str()),
2047 cache_write_failure: None,
2048 exit_code: None,
2049 timed_out: false,
2050 });
2051 return Ok(result);
2052 }
2053
2054 let mut output = match self.handle_focused_mode(¶ms, ct).await {
2056 Ok(v) => v,
2057 Err(e) => return Ok(err_to_tool_result(e)),
2058 };
2059
2060 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2062 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2063 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2064 ErrorData::new(
2065 rmcp::model::ErrorCode::INVALID_PARAMS,
2066 e.to_string(),
2067 Some(error_meta("validation", false, "invalid cursor format")),
2068 )
2069 }) {
2070 Ok(v) => v,
2071 Err(e) => return Ok(err_to_tool_result(e)),
2072 };
2073 cursor_data.offset
2074 } else {
2075 0
2076 };
2077
2078 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2080 decode_cursor(cursor_str)
2081 .map(|c| c.mode)
2082 .unwrap_or(PaginationMode::Callers)
2083 } else {
2084 PaginationMode::Callers
2085 };
2086
2087 let mut use_summary = params.output_control.summary == Some(true);
2088 if params.output_control.force == Some(true) {
2089 use_summary = false;
2090 }
2091 let verbose = params.output_control.verbose.unwrap_or(false);
2092
2093 let mut callee_cursor = match cursor_mode {
2094 PaginationMode::Callers => {
2095 let (paginated_items, paginated_next) = match paginate_focus_chains(
2096 &output.prod_chains,
2097 PaginationMode::Callers,
2098 offset,
2099 page_size,
2100 ) {
2101 Ok(v) => v,
2102 Err(e) => return Ok(err_to_tool_result(e)),
2103 };
2104
2105 if !use_summary
2106 && (paginated_next.is_some()
2107 || offset > 0
2108 || !verbose
2109 || !output.outgoing_chains.is_empty())
2110 {
2111 let base_path = Path::new(¶ms.path);
2112 output.formatted = format_focused_paginated(
2113 &paginated_items,
2114 output.prod_chains.len(),
2115 PaginationMode::Callers,
2116 ¶ms.symbol,
2117 &output.prod_chains,
2118 &output.test_chains,
2119 &output.outgoing_chains,
2120 output.def_count,
2121 offset,
2122 Some(base_path),
2123 verbose,
2124 );
2125 paginated_next
2126 } else {
2127 None
2128 }
2129 }
2130 PaginationMode::Callees => {
2131 let (paginated_items, paginated_next) = match paginate_focus_chains(
2132 &output.outgoing_chains,
2133 PaginationMode::Callees,
2134 offset,
2135 page_size,
2136 ) {
2137 Ok(v) => v,
2138 Err(e) => return Ok(err_to_tool_result(e)),
2139 };
2140
2141 if paginated_next.is_some() || offset > 0 || !verbose {
2142 let base_path = Path::new(¶ms.path);
2143 output.formatted = format_focused_paginated(
2144 &paginated_items,
2145 output.outgoing_chains.len(),
2146 PaginationMode::Callees,
2147 ¶ms.symbol,
2148 &output.prod_chains,
2149 &output.test_chains,
2150 &output.outgoing_chains,
2151 output.def_count,
2152 offset,
2153 Some(base_path),
2154 verbose,
2155 );
2156 paginated_next
2157 } else {
2158 None
2159 }
2160 }
2161 PaginationMode::Default => {
2162 return Ok(err_to_tool_result(ErrorData::new(
2163 rmcp::model::ErrorCode::INVALID_PARAMS,
2164 "invalid cursor: unknown pagination mode".to_string(),
2165 Some(error_meta(
2166 "validation",
2167 false,
2168 "use a cursor returned by a previous analyze_symbol call",
2169 )),
2170 )));
2171 }
2172 PaginationMode::DefUse => {
2173 let total_sites = output.def_use_sites.len();
2174 let (paginated_sites, paginated_next) = match paginate_slice(
2175 &output.def_use_sites,
2176 offset,
2177 page_size,
2178 PaginationMode::DefUse,
2179 ) {
2180 Ok(r) => (r.items, r.next_cursor),
2181 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2182 };
2183
2184 if !use_summary {
2187 let base_path = Path::new(¶ms.path);
2188 output.formatted = format_focused_paginated_defuse(
2189 &paginated_sites,
2190 total_sites,
2191 ¶ms.symbol,
2192 offset,
2193 Some(base_path),
2194 verbose,
2195 );
2196 }
2197
2198 output.def_use_sites = paginated_sites;
2201
2202 paginated_next
2203 }
2204 };
2205
2206 if callee_cursor.is_none()
2211 && cursor_mode == PaginationMode::Callers
2212 && !output.outgoing_chains.is_empty()
2213 && !use_summary
2214 && let Ok(cursor) = encode_cursor(&CursorData {
2215 mode: PaginationMode::Callees,
2216 offset: 0,
2217 })
2218 {
2219 callee_cursor = Some(cursor);
2220 }
2221
2222 if callee_cursor.is_none()
2229 && matches!(
2230 cursor_mode,
2231 PaginationMode::Callees | PaginationMode::Callers
2232 )
2233 && !output.def_use_sites.is_empty()
2234 && !use_summary
2235 && let Ok(cursor) = encode_cursor(&CursorData {
2236 mode: PaginationMode::DefUse,
2237 offset: 0,
2238 })
2239 {
2240 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2243 callee_cursor = Some(cursor);
2244 }
2245 }
2246
2247 output.next_cursor.clone_from(&callee_cursor);
2249
2250 let mut final_text = output.formatted.clone();
2252 if let Some(cursor) = callee_cursor {
2253 final_text.push('\n');
2254 final_text.push_str("NEXT_CURSOR: ");
2255 final_text.push_str(&cursor);
2256 }
2257
2258 tracing::Span::current().record("cache_tier", "Miss");
2260
2261 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2263 let mut meta = no_cache_meta().0;
2264 meta.insert(
2265 "content_hash".to_string(),
2266 serde_json::Value::String(content_hash),
2267 );
2268
2269 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2270 .with_meta(Some(Meta(meta)));
2271 if cursor_mode != PaginationMode::DefUse {
2275 output.def_use_sites = Vec::new();
2276 }
2277 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2278 result.structured_content = Some(structured);
2279 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2280 self.metrics_tx.send(crate::metrics::MetricEvent {
2281 ts: crate::metrics::unix_ms(),
2282 tool: "analyze_symbol",
2283 duration_ms: dur,
2284 output_chars: final_text.len(),
2285 param_path_depth: crate::metrics::path_component_count(¶m_path),
2286 max_depth: max_depth_val,
2287 result: "ok",
2288 error_type: None,
2289 session_id: sid,
2290 seq: Some(seq),
2291 cache_hit: Some(false),
2292 cache_tier: Some(CacheTier::Miss.as_str()),
2293 cache_write_failure: None,
2294 exit_code: None,
2295 timed_out: false,
2296 });
2297 Ok(result)
2298 }
2299
2300 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
2301 #[tool(
2302 name = "analyze_module",
2303 title = "Analyze Module",
2304 description = "Function and import index for a single source file with minimal token cost: name, line_count, language, function names with line numbers, import list only (~75% smaller than analyze_file). Fails if directory path supplied. Pagination, summary, force, verbose, git_ref not supported. Use analyze_file when you need signatures, types, or class details. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/analyze.rs?",
2305 output_schema = schema_for_type::<types::ModuleInfo>(),
2306 annotations(
2307 title = "Analyze Module",
2308 read_only_hint = true,
2309 destructive_hint = false,
2310 idempotent_hint = true,
2311 open_world_hint = false
2312 )
2313 )]
2314 async fn analyze_module(
2315 &self,
2316 params: Parameters<AnalyzeModuleParams>,
2317 context: RequestContext<RoleServer>,
2318 ) -> Result<CallToolResult, ErrorData> {
2319 let params = params.0;
2320 let session_id = self.session_id.lock().await.clone();
2322 let client_name = self.client_name.lock().await.clone();
2323 let client_version = self.client_version.lock().await.clone();
2324 extract_and_set_trace_context(
2325 Some(&context.meta),
2326 ClientMetadata {
2327 session_id,
2328 client_name,
2329 client_version,
2330 },
2331 );
2332 let span = tracing::Span::current();
2333 span.record("gen_ai.system", "mcp");
2334 span.record("gen_ai.operation.name", "execute_tool");
2335 span.record("gen_ai.tool.name", "analyze_module");
2336 span.record("path", ¶ms.path);
2337 let _validated_path = match validate_path(¶ms.path, true) {
2338 Ok(p) => p,
2339 Err(e) => {
2340 span.record("error", true);
2341 span.record("error.type", "invalid_params");
2342 return Ok(err_to_tool_result(e));
2343 }
2344 };
2345 let t_start = std::time::Instant::now();
2346 let param_path = params.path.clone();
2347 let seq = self
2348 .session_call_seq
2349 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2350 let sid = self.session_id.lock().await.clone();
2351
2352 if std::fs::metadata(¶ms.path)
2354 .map(|m| m.is_dir())
2355 .unwrap_or(false)
2356 {
2357 span.record("error", true);
2358 span.record("error.type", "invalid_params");
2359 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2360 self.metrics_tx.send(crate::metrics::MetricEvent {
2361 ts: crate::metrics::unix_ms(),
2362 tool: "analyze_module",
2363 duration_ms: dur,
2364 output_chars: 0,
2365 param_path_depth: crate::metrics::path_component_count(¶m_path),
2366 max_depth: None,
2367 result: "error",
2368 error_type: Some("invalid_params".to_string()),
2369 session_id: sid.clone(),
2370 seq: Some(seq),
2371 cache_hit: None,
2372 cache_write_failure: None,
2373 cache_tier: None,
2374 exit_code: None,
2375 timed_out: false,
2376 });
2377 return Ok(err_to_tool_result(ErrorData::new(
2378 rmcp::model::ErrorCode::INVALID_PARAMS,
2379 format!(
2380 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2381 params.path
2382 ),
2383 Some(error_meta(
2384 "validation",
2385 false,
2386 "use analyze_directory for directories",
2387 )),
2388 )));
2389 }
2390
2391 let mut analyze_file_params: AnalyzeFileParams = Default::default();
2393 analyze_file_params.path = params.path.clone();
2394 let (arc_output, module_tier) =
2395 match self.handle_file_details_mode(&analyze_file_params).await {
2396 Ok((output, tier)) => (output, tier),
2397 Err(e) => {
2398 let error_data = match e.code {
2399 rmcp::model::ErrorCode::INVALID_PARAMS => e,
2400 _ => ErrorData::new(
2401 rmcp::model::ErrorCode::INTERNAL_ERROR,
2402 format!("Failed to analyze module: {}", e.message),
2403 Some(error_meta("internal", false, "report this as a bug")),
2404 ),
2405 };
2406 return Ok(err_to_tool_result(error_data));
2407 }
2408 };
2409
2410 let file_path = std::path::Path::new(¶ms.path);
2412 let name = file_path
2413 .file_name()
2414 .and_then(|n: &std::ffi::OsStr| n.to_str())
2415 .unwrap_or("unknown")
2416 .to_string();
2417 let language = file_path
2418 .extension()
2419 .and_then(|e| e.to_str())
2420 .and_then(aptu_coder_core::lang::language_for_extension)
2421 .unwrap_or("unknown")
2422 .to_string();
2423 let functions = arc_output
2424 .semantic
2425 .functions
2426 .iter()
2427 .map(|f| {
2428 let mut mfi = types::ModuleFunctionInfo::default();
2429 mfi.name = f.name.clone();
2430 mfi.line = f.line;
2431 mfi
2432 })
2433 .collect();
2434 let imports = arc_output
2435 .semantic
2436 .imports
2437 .iter()
2438 .map(|i| {
2439 let mut mii = types::ModuleImportInfo::default();
2440 mii.module = i.module.clone();
2441 mii.items = i.items.clone();
2442 mii
2443 })
2444 .collect();
2445 let module_info =
2446 types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2447
2448 let text = format_module_info(&module_info);
2449
2450 tracing::Span::current().record("cache_tier", module_tier.as_str());
2452
2453 let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2455 let mut meta = no_cache_meta().0;
2456 meta.insert(
2457 "content_hash".to_string(),
2458 serde_json::Value::String(content_hash),
2459 );
2460
2461 let mut result =
2462 CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2463 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2464 ErrorData::new(
2465 rmcp::model::ErrorCode::INTERNAL_ERROR,
2466 format!("serialization failed: {e}"),
2467 Some(error_meta("internal", false, "report this as a bug")),
2468 )
2469 }) {
2470 Ok(v) => v,
2471 Err(e) => return Ok(err_to_tool_result(e)),
2472 };
2473 result.structured_content = Some(structured);
2474 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2475 self.metrics_tx.send(crate::metrics::MetricEvent {
2476 ts: crate::metrics::unix_ms(),
2477 tool: "analyze_module",
2478 duration_ms: dur,
2479 output_chars: text.len(),
2480 param_path_depth: crate::metrics::path_component_count(¶m_path),
2481 max_depth: None,
2482 result: "ok",
2483 error_type: None,
2484 session_id: sid,
2485 seq: Some(seq),
2486 cache_hit: Some(module_tier != CacheTier::Miss),
2487 cache_tier: Some(module_tier.as_str()),
2488 cache_write_failure: None,
2489 exit_code: None,
2490 timed_out: false,
2491 });
2492 Ok(result)
2493 }
2494
2495 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2496 #[tool(
2497 name = "edit_overwrite",
2498 title = "Edit Overwrite",
2499 description = "Creates or overwrites a file with UTF-8 content; creates parent directories if needed. Returns path, bytes_written. Fails if directory path supplied. AST-unaware (no language constraint). Use edit_replace for targeted single-block edits. working_dir sets the base directory for path resolution (default: server CWD). Example queries: Overwrite src/config.rs with updated content.",
2500 output_schema = schema_for_type::<EditOverwriteOutput>(),
2501 annotations(
2502 title = "Edit Overwrite",
2503 read_only_hint = false,
2504 destructive_hint = true,
2505 idempotent_hint = false,
2506 open_world_hint = false
2507 )
2508 )]
2509 async fn edit_overwrite(
2510 &self,
2511 params: Parameters<EditOverwriteParams>,
2512 context: RequestContext<RoleServer>,
2513 ) -> Result<CallToolResult, ErrorData> {
2514 let params = params.0;
2515 let session_id = self.session_id.lock().await.clone();
2517 let client_name = self.client_name.lock().await.clone();
2518 let client_version = self.client_version.lock().await.clone();
2519 extract_and_set_trace_context(
2520 Some(&context.meta),
2521 ClientMetadata {
2522 session_id,
2523 client_name,
2524 client_version,
2525 },
2526 );
2527 let span = tracing::Span::current();
2528 span.record("gen_ai.system", "mcp");
2529 span.record("gen_ai.operation.name", "execute_tool");
2530 span.record("gen_ai.tool.name", "edit_overwrite");
2531 span.record("path", ¶ms.path);
2532 let _validated_path = if let Some(ref wd) = params.working_dir {
2533 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2534 Ok(p) => p,
2535 Err(e) => {
2536 span.record("error", true);
2537 span.record("error.type", "invalid_params");
2538 return Ok(err_to_tool_result(e));
2539 }
2540 }
2541 } else {
2542 match validate_path(¶ms.path, false) {
2543 Ok(p) => p,
2544 Err(e) => {
2545 span.record("error", true);
2546 span.record("error.type", "invalid_params");
2547 return Ok(err_to_tool_result(e));
2548 }
2549 }
2550 };
2551 let t_start = std::time::Instant::now();
2552 let param_path = params.path.clone();
2553 let seq = self
2554 .session_call_seq
2555 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2556 let sid = self.session_id.lock().await.clone();
2557
2558 if std::fs::metadata(¶ms.path)
2560 .map(|m| m.is_dir())
2561 .unwrap_or(false)
2562 {
2563 span.record("error", true);
2564 span.record("error.type", "invalid_params");
2565 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2566 self.metrics_tx.send(crate::metrics::MetricEvent {
2567 ts: crate::metrics::unix_ms(),
2568 tool: "edit_overwrite",
2569 duration_ms: dur,
2570 output_chars: 0,
2571 param_path_depth: crate::metrics::path_component_count(¶m_path),
2572 max_depth: None,
2573 result: "error",
2574 error_type: Some("invalid_params".to_string()),
2575 session_id: sid.clone(),
2576 seq: Some(seq),
2577 cache_hit: None,
2578 cache_write_failure: None,
2579 cache_tier: None,
2580 exit_code: None,
2581 timed_out: false,
2582 });
2583 return Ok(err_to_tool_result(ErrorData::new(
2584 rmcp::model::ErrorCode::INVALID_PARAMS,
2585 "path is a directory; cannot write to a directory".to_string(),
2586 Some(error_meta(
2587 "validation",
2588 false,
2589 "provide a file path, not a directory",
2590 )),
2591 )));
2592 }
2593
2594 let path = std::path::PathBuf::from(¶ms.path);
2595 let content = params.content.clone();
2596 let handle = tokio::task::spawn_blocking(move || {
2597 aptu_coder_core::edit_overwrite_content(&path, &content)
2598 });
2599
2600 let output = match handle.await {
2601 Ok(Ok(v)) => v,
2602 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2603 span.record("error", true);
2604 span.record("error.type", "invalid_params");
2605 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2606 self.metrics_tx.send(crate::metrics::MetricEvent {
2607 ts: crate::metrics::unix_ms(),
2608 tool: "edit_overwrite",
2609 duration_ms: dur,
2610 output_chars: 0,
2611 param_path_depth: crate::metrics::path_component_count(¶m_path),
2612 max_depth: None,
2613 result: "error",
2614 error_type: Some("invalid_params".to_string()),
2615 session_id: sid.clone(),
2616 seq: Some(seq),
2617 cache_hit: None,
2618 cache_write_failure: None,
2619 cache_tier: None,
2620 exit_code: None,
2621 timed_out: false,
2622 });
2623 return Ok(err_to_tool_result(ErrorData::new(
2624 rmcp::model::ErrorCode::INVALID_PARAMS,
2625 "path is a directory".to_string(),
2626 Some(error_meta(
2627 "validation",
2628 false,
2629 "provide a file path, not a directory",
2630 )),
2631 )));
2632 }
2633 Ok(Err(e)) => {
2634 span.record("error", true);
2635 span.record("error.type", "internal_error");
2636 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2637 self.metrics_tx.send(crate::metrics::MetricEvent {
2638 ts: crate::metrics::unix_ms(),
2639 tool: "edit_overwrite",
2640 duration_ms: dur,
2641 output_chars: 0,
2642 param_path_depth: crate::metrics::path_component_count(¶m_path),
2643 max_depth: None,
2644 result: "error",
2645 error_type: Some("internal_error".to_string()),
2646 session_id: sid.clone(),
2647 seq: Some(seq),
2648 cache_hit: None,
2649 cache_write_failure: None,
2650 cache_tier: None,
2651 exit_code: None,
2652 timed_out: false,
2653 });
2654 return Ok(err_to_tool_result(ErrorData::new(
2655 rmcp::model::ErrorCode::INTERNAL_ERROR,
2656 e.to_string(),
2657 Some(error_meta(
2658 "resource",
2659 false,
2660 "check file path and permissions",
2661 )),
2662 )));
2663 }
2664 Err(e) => {
2665 span.record("error", true);
2666 span.record("error.type", "internal_error");
2667 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2668 self.metrics_tx.send(crate::metrics::MetricEvent {
2669 ts: crate::metrics::unix_ms(),
2670 tool: "edit_overwrite",
2671 duration_ms: dur,
2672 output_chars: 0,
2673 param_path_depth: crate::metrics::path_component_count(¶m_path),
2674 max_depth: None,
2675 result: "error",
2676 error_type: Some("internal_error".to_string()),
2677 session_id: sid.clone(),
2678 seq: Some(seq),
2679 cache_hit: None,
2680 cache_write_failure: None,
2681 cache_tier: None,
2682 exit_code: None,
2683 timed_out: false,
2684 });
2685 return Ok(err_to_tool_result(ErrorData::new(
2686 rmcp::model::ErrorCode::INTERNAL_ERROR,
2687 e.to_string(),
2688 Some(error_meta(
2689 "resource",
2690 false,
2691 "check file path and permissions",
2692 )),
2693 )));
2694 }
2695 };
2696
2697 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2698 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2699 .with_meta(Some(no_cache_meta()));
2700 let structured = match serde_json::to_value(&output).map_err(|e| {
2701 ErrorData::new(
2702 rmcp::model::ErrorCode::INTERNAL_ERROR,
2703 format!("serialization failed: {e}"),
2704 Some(error_meta("internal", false, "report this as a bug")),
2705 )
2706 }) {
2707 Ok(v) => v,
2708 Err(e) => return Ok(err_to_tool_result(e)),
2709 };
2710 result.structured_content = Some(structured);
2711 self.cache
2712 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2713 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2714 self.metrics_tx.send(crate::metrics::MetricEvent {
2715 ts: crate::metrics::unix_ms(),
2716 tool: "edit_overwrite",
2717 duration_ms: dur,
2718 output_chars: text.len(),
2719 param_path_depth: crate::metrics::path_component_count(¶m_path),
2720 max_depth: None,
2721 result: "ok",
2722 error_type: None,
2723 session_id: sid,
2724 seq: Some(seq),
2725 cache_hit: None,
2726 cache_write_failure: None,
2727 cache_tier: None,
2728 exit_code: None,
2729 timed_out: false,
2730 });
2731 Ok(result)
2732 }
2733
2734 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2735 #[tool(
2736 name = "edit_replace",
2737 title = "Edit Replace",
2738 description = "Replaces a unique exact text block; old_text must match character-for-character and appear exactly once. Returns path, bytes_before, bytes_after. Fails if zero matches; fails if multiple matches (extend old_text to be more specific). If invalid_params is returned, re-read the target file with analyze_file or analyze_module before retrying. Whitespace-sensitive exact match. Use edit_overwrite to replace the whole file. working_dir sets the base directory for path resolution (default: server CWD). Example queries: Update the function signature in lib.rs.",
2739 output_schema = schema_for_type::<EditReplaceOutput>(),
2740 annotations(
2741 title = "Edit Replace",
2742 read_only_hint = false,
2743 destructive_hint = true,
2744 idempotent_hint = false,
2745 open_world_hint = false
2746 )
2747 )]
2748 async fn edit_replace(
2749 &self,
2750 params: Parameters<EditReplaceParams>,
2751 context: RequestContext<RoleServer>,
2752 ) -> Result<CallToolResult, ErrorData> {
2753 let params = params.0;
2754 let session_id = self.session_id.lock().await.clone();
2756 let client_name = self.client_name.lock().await.clone();
2757 let client_version = self.client_version.lock().await.clone();
2758 extract_and_set_trace_context(
2759 Some(&context.meta),
2760 ClientMetadata {
2761 session_id,
2762 client_name,
2763 client_version,
2764 },
2765 );
2766 let span = tracing::Span::current();
2767 span.record("gen_ai.system", "mcp");
2768 span.record("gen_ai.operation.name", "execute_tool");
2769 span.record("gen_ai.tool.name", "edit_replace");
2770 span.record("path", ¶ms.path);
2771 let _validated_path = if let Some(ref wd) = params.working_dir {
2772 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2773 Ok(p) => p,
2774 Err(e) => {
2775 span.record("error", true);
2776 span.record("error.type", "invalid_params");
2777 return Ok(err_to_tool_result(e));
2778 }
2779 }
2780 } else {
2781 match validate_path(¶ms.path, true) {
2782 Ok(p) => p,
2783 Err(e) => {
2784 span.record("error", true);
2785 span.record("error.type", "invalid_params");
2786 return Ok(err_to_tool_result(e));
2787 }
2788 }
2789 };
2790 let t_start = std::time::Instant::now();
2791 let param_path = params.path.clone();
2792 let seq = self
2793 .session_call_seq
2794 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2795 let sid = self.session_id.lock().await.clone();
2796
2797 if std::fs::metadata(¶ms.path)
2799 .map(|m| m.is_dir())
2800 .unwrap_or(false)
2801 {
2802 span.record("error", true);
2803 span.record("error.type", "invalid_params");
2804 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2805 self.metrics_tx.send(crate::metrics::MetricEvent {
2806 ts: crate::metrics::unix_ms(),
2807 tool: "edit_replace",
2808 duration_ms: dur,
2809 output_chars: 0,
2810 param_path_depth: crate::metrics::path_component_count(¶m_path),
2811 max_depth: None,
2812 result: "error",
2813 error_type: Some("invalid_params".to_string()),
2814 session_id: sid.clone(),
2815 seq: Some(seq),
2816 cache_hit: None,
2817 cache_write_failure: None,
2818 cache_tier: None,
2819 exit_code: None,
2820 timed_out: false,
2821 });
2822 return Ok(err_to_tool_result(ErrorData::new(
2823 rmcp::model::ErrorCode::INVALID_PARAMS,
2824 "path is a directory; cannot edit a directory".to_string(),
2825 Some(error_meta(
2826 "validation",
2827 false,
2828 "provide a file path, not a directory",
2829 )),
2830 )));
2831 }
2832
2833 let path = std::path::PathBuf::from(¶ms.path);
2834 let old_text = params.old_text.clone();
2835 let new_text = params.new_text.clone();
2836 let handle = tokio::task::spawn_blocking(move || {
2837 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2838 });
2839
2840 let output = match handle.await {
2841 Ok(Ok(v)) => v,
2842 Ok(Err(aptu_coder_core::EditError::NotFound {
2843 path: notfound_path,
2844 })) => {
2845 span.record("error", true);
2846 span.record("error.type", "invalid_params");
2847 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2848 self.metrics_tx.send(crate::metrics::MetricEvent {
2849 ts: crate::metrics::unix_ms(),
2850 tool: "edit_replace",
2851 duration_ms: dur,
2852 output_chars: 0,
2853 param_path_depth: crate::metrics::path_component_count(¶m_path),
2854 max_depth: None,
2855 result: "error",
2856 error_type: Some("invalid_params".to_string()),
2857 session_id: sid.clone(),
2858 seq: Some(seq),
2859 cache_hit: None,
2860 cache_write_failure: None,
2861 cache_tier: None,
2862 exit_code: None,
2863 timed_out: false,
2864 });
2865 return Ok(err_to_tool_result(ErrorData::new(
2866 rmcp::model::ErrorCode::INVALID_PARAMS,
2867 format!(
2868 "old_text not found (0 matches) in {notfound_path}. Re-read the file with analyze_file or analyze_module to obtain the current content, then derive old_text from the live file before retrying."
2869 ),
2870 Some(error_meta(
2871 "validation",
2872 false,
2873 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2874 )),
2875 )));
2876 }
2877 Ok(Err(aptu_coder_core::EditError::Ambiguous {
2878 count,
2879 path: ambiguous_path,
2880 })) => {
2881 span.record("error", true);
2882 span.record("error.type", "invalid_params");
2883 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2884 self.metrics_tx.send(crate::metrics::MetricEvent {
2885 ts: crate::metrics::unix_ms(),
2886 tool: "edit_replace",
2887 duration_ms: dur,
2888 output_chars: 0,
2889 param_path_depth: crate::metrics::path_component_count(¶m_path),
2890 max_depth: None,
2891 result: "error",
2892 error_type: Some("invalid_params".to_string()),
2893 session_id: sid.clone(),
2894 seq: Some(seq),
2895 cache_hit: None,
2896 cache_write_failure: None,
2897 cache_tier: None,
2898 exit_code: None,
2899 timed_out: false,
2900 });
2901 return Ok(err_to_tool_result(ErrorData::new(
2902 rmcp::model::ErrorCode::INVALID_PARAMS,
2903 format!(
2904 "old_text matched {count} locations in {ambiguous_path}. Extend old_text with more surrounding context to make it unique, or re-read with analyze_file to confirm the exact text."
2905 ),
2906 Some(error_meta(
2907 "validation",
2908 false,
2909 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2910 )),
2911 )));
2912 }
2913 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2914 span.record("error", true);
2915 span.record("error.type", "invalid_params");
2916 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2917 self.metrics_tx.send(crate::metrics::MetricEvent {
2918 ts: crate::metrics::unix_ms(),
2919 tool: "edit_replace",
2920 duration_ms: dur,
2921 output_chars: 0,
2922 param_path_depth: crate::metrics::path_component_count(¶m_path),
2923 max_depth: None,
2924 result: "error",
2925 error_type: Some("invalid_params".to_string()),
2926 session_id: sid.clone(),
2927 seq: Some(seq),
2928 cache_hit: None,
2929 cache_write_failure: None,
2930 cache_tier: None,
2931 exit_code: None,
2932 timed_out: false,
2933 });
2934 return Ok(err_to_tool_result(ErrorData::new(
2935 rmcp::model::ErrorCode::INVALID_PARAMS,
2936 "path is a directory".to_string(),
2937 Some(error_meta(
2938 "validation",
2939 false,
2940 "provide a file path, not a directory",
2941 )),
2942 )));
2943 }
2944 Ok(Err(e)) => {
2945 span.record("error", true);
2946 span.record("error.type", "internal_error");
2947 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2948 self.metrics_tx.send(crate::metrics::MetricEvent {
2949 ts: crate::metrics::unix_ms(),
2950 tool: "edit_replace",
2951 duration_ms: dur,
2952 output_chars: 0,
2953 param_path_depth: crate::metrics::path_component_count(¶m_path),
2954 max_depth: None,
2955 result: "error",
2956 error_type: Some("internal_error".to_string()),
2957 session_id: sid.clone(),
2958 seq: Some(seq),
2959 cache_hit: None,
2960 cache_write_failure: None,
2961 cache_tier: None,
2962 exit_code: None,
2963 timed_out: false,
2964 });
2965 return Ok(err_to_tool_result(ErrorData::new(
2966 rmcp::model::ErrorCode::INTERNAL_ERROR,
2967 e.to_string(),
2968 Some(error_meta(
2969 "resource",
2970 false,
2971 "check file path and permissions",
2972 )),
2973 )));
2974 }
2975 Err(e) => {
2976 span.record("error", true);
2977 span.record("error.type", "internal_error");
2978 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2979 self.metrics_tx.send(crate::metrics::MetricEvent {
2980 ts: crate::metrics::unix_ms(),
2981 tool: "edit_replace",
2982 duration_ms: dur,
2983 output_chars: 0,
2984 param_path_depth: crate::metrics::path_component_count(¶m_path),
2985 max_depth: None,
2986 result: "error",
2987 error_type: Some("internal_error".to_string()),
2988 session_id: sid.clone(),
2989 seq: Some(seq),
2990 cache_hit: None,
2991 cache_write_failure: None,
2992 cache_tier: None,
2993 exit_code: None,
2994 timed_out: false,
2995 });
2996 return Ok(err_to_tool_result(ErrorData::new(
2997 rmcp::model::ErrorCode::INTERNAL_ERROR,
2998 e.to_string(),
2999 Some(error_meta(
3000 "resource",
3001 false,
3002 "check file path and permissions",
3003 )),
3004 )));
3005 }
3006 };
3007
3008 let text = format!(
3009 "Edited {}: {} bytes -> {} bytes",
3010 output.path, output.bytes_before, output.bytes_after
3011 );
3012 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3013 .with_meta(Some(no_cache_meta()));
3014 let structured = match serde_json::to_value(&output).map_err(|e| {
3015 ErrorData::new(
3016 rmcp::model::ErrorCode::INTERNAL_ERROR,
3017 format!("serialization failed: {e}"),
3018 Some(error_meta("internal", false, "report this as a bug")),
3019 )
3020 }) {
3021 Ok(v) => v,
3022 Err(e) => return Ok(err_to_tool_result(e)),
3023 };
3024 result.structured_content = Some(structured);
3025 self.cache
3026 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3027 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3028 self.metrics_tx.send(crate::metrics::MetricEvent {
3029 ts: crate::metrics::unix_ms(),
3030 tool: "edit_replace",
3031 duration_ms: dur,
3032 output_chars: text.len(),
3033 param_path_depth: crate::metrics::path_component_count(¶m_path),
3034 max_depth: None,
3035 result: "ok",
3036 error_type: None,
3037 session_id: sid,
3038 seq: Some(seq),
3039 cache_hit: None,
3040 cache_write_failure: None,
3041 cache_tier: None,
3042 exit_code: None,
3043 timed_out: false,
3044 });
3045 Ok(result)
3046 }
3047
3048 #[tool(
3049 name = "exec_command",
3050 title = "Exec Command",
3051 description = "Execute shell command via sh -c (or $SHELL if set). Returns stdout, stderr, interleaved, exit_code, timed_out, output_truncated. Output capped at 2000 lines and 50 KB per stream; use timeout_secs to limit execution time. working_dir sets initial working directory; cd and absolute paths in command string bypass this restriction. Fails if working_dir does not exist, is not a directory, or is outside CWD. Pass stdin to pipe UTF-8 content into the process (max 1 MB). For file creation and edits, prefer the edit_* tools. Example queries: Run the test suite and capture output.",
3052 output_schema = schema_for_type::<types::ShellOutput>(),
3053 annotations(
3054 title = "Exec Command",
3055 read_only_hint = false,
3056 destructive_hint = true,
3057 idempotent_hint = false,
3058 open_world_hint = true
3059 )
3060 )]
3061 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, command = tracing::field::Empty, exit_code = tracing::field::Empty, timed_out = tracing::field::Empty, output_truncated = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
3062 pub async fn exec_command(
3063 &self,
3064 params: Parameters<types::ExecCommandParams>,
3065 context: RequestContext<RoleServer>,
3066 ) -> Result<CallToolResult, ErrorData> {
3067 let t_start = std::time::Instant::now();
3068 let params = params.0;
3069 let session_id = self.session_id.lock().await.clone();
3071 let client_name = self.client_name.lock().await.clone();
3072 let client_version = self.client_version.lock().await.clone();
3073 extract_and_set_trace_context(
3074 Some(&context.meta),
3075 ClientMetadata {
3076 session_id,
3077 client_name,
3078 client_version,
3079 },
3080 );
3081 let span = tracing::Span::current();
3082 span.record("gen_ai.system", "mcp");
3083 span.record("gen_ai.operation.name", "execute_tool");
3084 span.record("gen_ai.tool.name", "exec_command");
3085 span.record("command", ¶ms.command);
3086
3087 let working_dir_path = if let Some(ref wd) = params.working_dir {
3089 match validate_path(wd, true) {
3090 Ok(p) => {
3091 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3093 span.record("error", true);
3094 span.record("error.type", "invalid_params");
3095 return Ok(err_to_tool_result(ErrorData::new(
3096 rmcp::model::ErrorCode::INVALID_PARAMS,
3097 "working_dir must be a directory".to_string(),
3098 Some(error_meta(
3099 "validation",
3100 false,
3101 "provide a valid directory path",
3102 )),
3103 )));
3104 }
3105 Some(p)
3106 }
3107 Err(e) => {
3108 span.record("error", true);
3109 span.record("error.type", "invalid_params");
3110 return Ok(err_to_tool_result(e));
3111 }
3112 }
3113 } else {
3114 None
3115 };
3116
3117 let param_path = params.working_dir.clone();
3118 let seq = self
3119 .session_call_seq
3120 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3121 let sid = self.session_id.lock().await.clone();
3122
3123 if let Some(ref stdin_content) = params.stdin
3125 && stdin_content.len() > STDIN_MAX_BYTES
3126 {
3127 span.record("error", true);
3128 span.record("error.type", "invalid_params");
3129 return Ok(err_to_tool_result(ErrorData::new(
3130 rmcp::model::ErrorCode::INVALID_PARAMS,
3131 "stdin exceeds 1 MB limit".to_string(),
3132 Some(error_meta("validation", false, "reduce stdin content size")),
3133 )));
3134 }
3135
3136 let command = params.command.clone();
3137 let timeout_secs = params.timeout_secs;
3138
3139 let _cache_key = (
3141 command.clone(),
3142 working_dir_path
3143 .as_ref()
3144 .map(|p| p.display().to_string())
3145 .unwrap_or_default(),
3146 );
3147 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3149 let output = run_exec_impl(
3150 command.clone(),
3151 working_dir_path.clone(),
3152 timeout_secs,
3153 params.memory_limit_mb,
3154 params.cpu_limit_secs,
3155 params.stdin.clone(),
3156 seq,
3157 resolved_path_str,
3158 )
3159 .await;
3160
3161 let exit_code = output.exit_code;
3162 let timed_out = output.timed_out;
3163 let output_truncated = output.output_truncated;
3164
3165 if let Some(code) = exit_code {
3167 span.record("exit_code", code);
3168 }
3169 span.record("timed_out", timed_out);
3170 span.record("output_truncated", output_truncated);
3171
3172 if output_truncated {
3174 tracing::debug!(truncated = true, message = "output truncated");
3175 }
3176
3177 let output_text = if output.interleaved.is_empty() {
3179 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3180 } else {
3181 format!("Output:\n{}", output.interleaved)
3182 };
3183
3184 let text = format!(
3185 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3186 params.command,
3187 exit_code
3188 .map(|c| c.to_string())
3189 .unwrap_or_else(|| "null".to_string()),
3190 timed_out,
3191 output_truncated,
3192 output_text,
3193 );
3194
3195 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3196
3197 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3202
3203 let mut result = if command_failed {
3204 CallToolResult::error(content_blocks)
3205 } else {
3206 CallToolResult::success(content_blocks)
3207 }
3208 .with_meta(Some(no_cache_meta()));
3209
3210 let structured = match serde_json::to_value(&output).map_err(|e| {
3211 ErrorData::new(
3212 rmcp::model::ErrorCode::INTERNAL_ERROR,
3213 format!("serialization failed: {e}"),
3214 Some(error_meta("internal", false, "report this as a bug")),
3215 )
3216 }) {
3217 Ok(v) => v,
3218 Err(e) => {
3219 span.record("error", true);
3220 span.record("error.type", "internal_error");
3221 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3222 self.metrics_tx.send(crate::metrics::MetricEvent {
3223 ts: crate::metrics::unix_ms(),
3224 tool: "exec_command",
3225 duration_ms: dur,
3226 output_chars: 0,
3227 param_path_depth: crate::metrics::path_component_count(
3228 param_path.as_deref().unwrap_or(""),
3229 ),
3230 max_depth: None,
3231 result: "error",
3232 error_type: Some("internal_error".to_string()),
3233 session_id: sid.clone(),
3234 seq: Some(seq),
3235 cache_hit: Some(false),
3236 cache_write_failure: None,
3237 cache_tier: None,
3238 exit_code,
3239 timed_out,
3240 });
3241 return Ok(err_to_tool_result(e));
3242 }
3243 };
3244
3245 result.structured_content = Some(structured);
3246 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3247 self.metrics_tx.send(crate::metrics::MetricEvent {
3248 ts: crate::metrics::unix_ms(),
3249 tool: "exec_command",
3250 duration_ms: dur,
3251 output_chars: text.len(),
3252 param_path_depth: crate::metrics::path_component_count(
3253 param_path.as_deref().unwrap_or(""),
3254 ),
3255 max_depth: None,
3256 result: "ok",
3257 error_type: None,
3258 session_id: sid,
3259 seq: Some(seq),
3260 cache_hit: Some(false),
3261 cache_write_failure: None,
3262 cache_tier: None,
3263 exit_code,
3264 timed_out,
3265 });
3266 Ok(result)
3267 }
3268}
3269
3270fn build_exec_command(
3272 command: &str,
3273 working_dir_path: Option<&std::path::PathBuf>,
3274 memory_limit_mb: Option<u64>,
3275 cpu_limit_secs: Option<u64>,
3276 stdin_present: bool,
3277 resolved_path: Option<&str>,
3278) -> tokio::process::Command {
3279 let shell = resolve_shell();
3280 let mut cmd = tokio::process::Command::new(shell);
3281 cmd.arg("-c").arg(command);
3282
3283 if let Some(wd) = working_dir_path {
3284 cmd.current_dir(wd);
3285 }
3286
3287 if let Some(path) = resolved_path {
3289 cmd.env("PATH", path);
3290 }
3291
3292 cmd.stdout(std::process::Stdio::piped())
3293 .stderr(std::process::Stdio::piped());
3294
3295 if stdin_present {
3296 cmd.stdin(std::process::Stdio::piped());
3297 } else {
3298 cmd.stdin(std::process::Stdio::null());
3299 }
3300
3301 #[cfg(unix)]
3302 {
3303 #[cfg(not(target_os = "linux"))]
3304 if memory_limit_mb.is_some() {
3305 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3306 }
3307 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3308 unsafe {
3312 cmd.pre_exec(move || {
3313 #[cfg(target_os = "linux")]
3314 if let Some(mb) = memory_limit_mb {
3315 let bytes = mb.saturating_mul(1024 * 1024);
3316 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3317 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3318 }
3319 if let Some(cpu) = cpu_limit_secs {
3320 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3321 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3322 }
3323 Ok(())
3324 });
3325 }
3326 }
3327 }
3328
3329 cmd
3330}
3331
3332async fn run_with_timeout(
3335 mut child: tokio::process::Child,
3336 timeout_secs: Option<u64>,
3337 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3338) -> (Option<i32>, bool, bool, Option<String>) {
3339 use tokio::io::AsyncBufReadExt as _;
3340 use tokio_stream::StreamExt as TokioStreamExt;
3341 use tokio_stream::wrappers::LinesStream;
3342
3343 let stdout_pipe = child.stdout.take();
3344 let stderr_pipe = child.stderr.take();
3345
3346 let mut drain_task = tokio::spawn(async move {
3347 let so_stream = stdout_pipe.map(|p| {
3348 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3349 });
3350 let se_stream = stderr_pipe.map(|p| {
3351 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3352 });
3353
3354 match (so_stream, se_stream) {
3355 (Some(so), Some(se)) => {
3356 let mut merged = so.merge(se);
3357 while let Some(Ok((is_stderr, line))) = merged.next().await {
3358 let _ = tx.send((is_stderr, line));
3359 }
3360 }
3361 (Some(so), None) => {
3362 let mut stream = so;
3363 while let Some(Ok((_, line))) = stream.next().await {
3364 let _ = tx.send((false, line));
3365 }
3366 }
3367 (None, Some(se)) => {
3368 let mut stream = se;
3369 while let Some(Ok((_, line))) = stream.next().await {
3370 let _ = tx.send((true, line));
3371 }
3372 }
3373 (None, None) => {}
3374 }
3375 });
3376
3377 tokio::select! {
3378 _ = &mut drain_task => {
3379 let (status, drain_truncated) = match tokio::time::timeout(
3380 std::time::Duration::from_millis(500),
3381 child.wait()
3382 ).await {
3383 Ok(Ok(s)) => (Some(s), false),
3384 Ok(Err(_)) => (None, false),
3385 Err(_) => {
3386 child.start_kill().ok();
3387 let _ = child.wait().await;
3388 (None, true)
3389 }
3390 };
3391 let exit_code = status.and_then(|s| s.code());
3392 let ocerr = if drain_truncated {
3393 Some("post-exit drain timeout: background process held pipes".to_string())
3394 } else {
3395 None
3396 };
3397 (exit_code, false, drain_truncated, ocerr)
3398 }
3399 _ = async {
3400 if let Some(secs) = timeout_secs {
3401 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3402 } else {
3403 std::future::pending::<()>().await;
3404 }
3405 } => {
3406 let _ = child.kill().await;
3407 let _ = child.wait().await;
3408 drain_task.abort();
3409 (None, true, false, None)
3410 }
3411 }
3412}
3413
3414#[allow(clippy::too_many_arguments)]
3418async fn run_exec_impl(
3419 command: String,
3420 working_dir_path: Option<std::path::PathBuf>,
3421 timeout_secs: Option<u64>,
3422 memory_limit_mb: Option<u64>,
3423 cpu_limit_secs: Option<u64>,
3424 stdin: Option<String>,
3425 seq: u32,
3426 resolved_path: Option<&str>,
3427) -> types::ShellOutput {
3428 let mut cmd = build_exec_command(
3429 &command,
3430 working_dir_path.as_ref(),
3431 memory_limit_mb,
3432 cpu_limit_secs,
3433 stdin.is_some(),
3434 resolved_path,
3435 );
3436
3437 let mut child = match cmd.spawn() {
3438 Ok(c) => c,
3439 Err(e) => {
3440 return types::ShellOutput::new(
3441 String::new(),
3442 format!("failed to spawn command: {e}"),
3443 format!("failed to spawn command: {e}"),
3444 None,
3445 false,
3446 false,
3447 );
3448 }
3449 };
3450
3451 if let Some(stdin_content) = stdin
3452 && let Some(mut stdin_handle) = child.stdin.take()
3453 {
3454 use tokio::io::AsyncWriteExt as _;
3455 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3456 Ok(()) => {
3457 drop(stdin_handle);
3458 }
3459 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3460 Err(e) => {
3461 warn!("failed to write stdin: {e}");
3462 }
3463 }
3464 }
3465
3466 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3467
3468 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3469 run_with_timeout(child, timeout_secs, tx).await;
3470
3471 let mut lines: Vec<(bool, String)> = Vec::new();
3472 while let Some(item) = rx.recv().await {
3473 lines.push(item);
3474 }
3475
3476 const MAX_BYTES: usize = 50 * 1024;
3478 let mut stdout_str = String::new();
3479 let mut stderr_str = String::new();
3480 let mut interleaved_str = String::new();
3481 let mut so_bytes = 0usize;
3482 let mut se_bytes = 0usize;
3483 let mut il_bytes = 0usize;
3484 for (is_stderr, line) in &lines {
3485 let entry = format!("{line}\n");
3486 if il_bytes < 2 * MAX_BYTES {
3487 il_bytes += entry.len();
3488 interleaved_str.push_str(&entry);
3489 }
3490 if *is_stderr {
3491 if se_bytes < MAX_BYTES {
3492 se_bytes += entry.len();
3493 stderr_str.push_str(&entry);
3494 }
3495 } else if so_bytes < MAX_BYTES {
3496 so_bytes += entry.len();
3497 stdout_str.push_str(&entry);
3498 }
3499 }
3500
3501 let slot = seq % 8;
3502 let (stdout, stderr, stdout_path, stderr_path) =
3503 handle_output_persist(stdout_str, stderr_str, slot);
3504 output_truncated = output_truncated || stdout_path.is_some();
3505
3506 let mut output = types::ShellOutput::new(
3507 stdout,
3508 stderr,
3509 interleaved_str,
3510 exit_code,
3511 timed_out,
3512 output_truncated,
3513 );
3514 output.output_collection_error = output_collection_error;
3515 output.stdout_path = stdout_path;
3516 output.stderr_path = stderr_path;
3517
3518 output
3519}
3520
3521fn handle_output_persist(
3528 stdout: String,
3529 stderr: String,
3530 slot: u32,
3531) -> (String, String, Option<String>, Option<String>) {
3532 const MAX_OUTPUT_LINES: usize = 2000;
3533 const OVERFLOW_PREVIEW_LINES: usize = 50;
3534
3535 let stdout_lines: Vec<&str> = stdout.lines().collect();
3536 let stderr_lines: Vec<&str> = stderr.lines().collect();
3537
3538 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3540 return (stdout, stderr, None, None);
3541 }
3542
3543 let base = std::env::temp_dir()
3545 .join("aptu-coder-overflow")
3546 .join(format!("slot-{slot}"));
3547 let _ = std::fs::create_dir_all(&base);
3548
3549 let stdout_path = base.join("stdout");
3550 let stderr_path = base.join("stderr");
3551
3552 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3553 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3554
3555 let stdout_path_str = stdout_path.display().to_string();
3556 let stderr_path_str = stderr_path.display().to_string();
3557
3558 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3559 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3560 } else {
3561 stdout
3562 };
3563 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3564 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3565 } else {
3566 stderr
3567 };
3568
3569 (
3570 stdout_preview,
3571 stderr_preview,
3572 Some(stdout_path_str),
3573 Some(stderr_path_str),
3574 )
3575}
3576
3577#[derive(Clone)]
3581struct FocusedAnalysisParams {
3582 path: std::path::PathBuf,
3583 symbol: String,
3584 match_mode: SymbolMatchMode,
3585 follow_depth: u32,
3586 max_depth: Option<u32>,
3587 ast_recursion_limit: Option<usize>,
3588 use_summary: bool,
3589 impl_only: Option<bool>,
3590 def_use: bool,
3591 parse_timeout_micros: Option<u64>,
3592}
3593
3594fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3595 for tool in tools {
3596 router.disable_route(*tool);
3597 }
3598}
3599
3600#[tool_handler]
3601impl ServerHandler for CodeAnalyzer {
3602 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3603 async fn initialize(
3604 &self,
3605 request: InitializeRequestParams,
3606 context: RequestContext<RoleServer>,
3607 ) -> Result<InitializeResult, ErrorData> {
3608 let span = tracing::Span::current();
3609 span.record("service.name", "aptu-coder");
3610 span.record("service.version", env!("CARGO_PKG_VERSION"));
3611
3612 {
3614 let mut client_name_lock = self.client_name.lock().await;
3615 *client_name_lock = Some(request.client_info.name.clone());
3616 }
3617 {
3618 let mut client_version_lock = self.client_version.lock().await;
3619 *client_version_lock = Some(request.client_info.version.clone());
3620 }
3621
3622 if let Some(meta) = context.extensions.get::<Meta>() {
3625 let mut meta_lock = self.profile_meta.lock().await;
3626 *meta_lock = Some(meta.0.clone());
3627 }
3628 Ok(self.get_info())
3629 }
3630
3631 fn get_info(&self) -> InitializeResult {
3632 let excluded = crate::EXCLUDED_DIRS.join(", ");
3633 let instructions = format!(
3634 "Recommended workflow:\n\
3635 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3636 2. Re-run analyze_directory(path=<source_package>, max_depth=2, summary=true) for module map. Include test directories (tests/, *_test.go, test_*.py, test_*.rs, *.spec.ts, *.spec.js).\n\
3637 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3638 4. Use analyze_symbol to trace call graphs.\n\
3639 Prefer summary=true on 1000+ files. Set max_depth=2; increase if packages too large. Paginate with cursor/page_size. For subagents: DISABLE_PROMPT_CACHING=1."
3640 );
3641 let capabilities = ServerCapabilities::builder()
3642 .enable_logging()
3643 .enable_tools()
3644 .enable_tool_list_changed()
3645 .enable_completions()
3646 .build();
3647 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3648 .with_title("Aptu Coder")
3649 .with_description("MCP server for code structure analysis using tree-sitter");
3650 InitializeResult::new(capabilities)
3651 .with_server_info(server_info)
3652 .with_instructions(&instructions)
3653 }
3654
3655 async fn list_tools(
3656 &self,
3657 _request: Option<rmcp::model::PaginatedRequestParams>,
3658 _context: RequestContext<RoleServer>,
3659 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3660 let router = self.tool_router.read().await;
3661 Ok(rmcp::model::ListToolsResult {
3662 tools: router.list_all(),
3663 meta: None,
3664 next_cursor: None,
3665 })
3666 }
3667
3668 async fn call_tool(
3669 &self,
3670 request: rmcp::model::CallToolRequestParams,
3671 context: RequestContext<RoleServer>,
3672 ) -> Result<CallToolResult, ErrorData> {
3673 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3674 let router = self.tool_router.read().await;
3675 router.call(tcc).await
3676 }
3677
3678 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3679 let mut peer_lock = self.peer.lock().await;
3680 *peer_lock = Some(context.peer.clone());
3681 drop(peer_lock);
3682
3683 let millis = std::time::SystemTime::now()
3685 .duration_since(std::time::UNIX_EPOCH)
3686 .unwrap_or_default()
3687 .as_millis()
3688 .try_into()
3689 .unwrap_or(u64::MAX);
3690 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3691 let sid = format!("{millis}-{counter}");
3692 {
3693 let mut session_id_lock = self.session_id.lock().await;
3694 *session_id_lock = Some(sid);
3695 }
3696 self.session_call_seq
3697 .store(0, std::sync::atomic::Ordering::Relaxed);
3698
3699 let meta_lock = self.profile_meta.lock().await;
3709 let meta_profile = meta_lock
3710 .as_ref()
3711 .and_then(|m| m.get("io.clouatre-labs/profile"))
3712 .and_then(|v| v.as_str())
3713 .map(str::to_owned);
3714 drop(meta_lock);
3715
3716 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3718
3719 {
3720 let mut router = self.tool_router.write().await;
3721
3722 if let Some(ref profile) = active_profile {
3726 match profile.as_str() {
3727 "edit" => {
3728 disable_routes(
3730 &mut router,
3731 &[
3732 "analyze_directory",
3733 "analyze_file",
3734 "analyze_module",
3735 "analyze_symbol",
3736 ],
3737 );
3738 }
3739 "analyze" => {
3740 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
3742 }
3743 _ => {
3744 }
3746 }
3747 }
3748
3749 router.bind_peer_notifier(&context.peer);
3751 }
3752
3753 let peer = self.peer.clone();
3755 let event_rx = self.event_rx.clone();
3756
3757 tokio::spawn(async move {
3758 let rx = {
3759 let mut rx_lock = event_rx.lock().await;
3760 rx_lock.take()
3761 };
3762
3763 if let Some(mut receiver) = rx {
3764 let mut buffer = Vec::with_capacity(64);
3765 loop {
3766 receiver.recv_many(&mut buffer, 64).await;
3768
3769 if buffer.is_empty() {
3770 break;
3772 }
3773
3774 let peer_lock = peer.lock().await;
3776 if let Some(peer) = peer_lock.as_ref() {
3777 for log_event in buffer.drain(..) {
3778 let notification = ServerNotification::LoggingMessageNotification(
3779 Notification::new(LoggingMessageNotificationParam {
3780 level: log_event.level,
3781 logger: Some(log_event.logger),
3782 data: log_event.data,
3783 }),
3784 );
3785 if let Err(e) = peer.send_notification(notification).await {
3786 warn!("Failed to send logging notification: {}", e);
3787 }
3788 }
3789 }
3790 }
3791 }
3792 });
3793 }
3794
3795 #[instrument(skip(self, _context))]
3796 async fn on_cancelled(
3797 &self,
3798 notification: CancelledNotificationParam,
3799 _context: NotificationContext<RoleServer>,
3800 ) {
3801 tracing::info!(
3802 request_id = ?notification.request_id,
3803 reason = ?notification.reason,
3804 "Received cancellation notification"
3805 );
3806 }
3807
3808 #[instrument(skip(self, _context))]
3809 async fn complete(
3810 &self,
3811 request: CompleteRequestParams,
3812 _context: RequestContext<RoleServer>,
3813 ) -> Result<CompleteResult, ErrorData> {
3814 let argument_name = &request.argument.name;
3816 let argument_value = &request.argument.value;
3817
3818 let completions = match argument_name.as_str() {
3819 "path" => {
3820 let root = Path::new(".");
3822 completion::path_completions(root, argument_value)
3823 }
3824 "symbol" => {
3825 let path_arg = request
3827 .context
3828 .as_ref()
3829 .and_then(|ctx| ctx.get_argument("path"));
3830
3831 match path_arg {
3832 Some(path_str) => {
3833 let path = Path::new(path_str);
3834 completion::symbol_completions(&self.cache, path, argument_value)
3835 }
3836 None => Vec::new(),
3837 }
3838 }
3839 _ => Vec::new(),
3840 };
3841
3842 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3844 let (values, has_more) = if completions.len() > 100 {
3845 (completions.into_iter().take(100).collect(), true)
3846 } else {
3847 (completions, false)
3848 };
3849
3850 let completion_info =
3851 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3852 Ok(info) => info,
3853 Err(_) => {
3854 CompletionInfo::with_all_values(Vec::new())
3856 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3857 }
3858 };
3859
3860 Ok(CompleteResult::new(completion_info))
3861 }
3862
3863 async fn set_level(
3864 &self,
3865 params: SetLevelRequestParams,
3866 _context: RequestContext<RoleServer>,
3867 ) -> Result<(), ErrorData> {
3868 let level_filter = match params.level {
3869 LoggingLevel::Debug => LevelFilter::DEBUG,
3870 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3871 LoggingLevel::Warning => LevelFilter::WARN,
3872 LoggingLevel::Error
3873 | LoggingLevel::Critical
3874 | LoggingLevel::Alert
3875 | LoggingLevel::Emergency => LevelFilter::ERROR,
3876 };
3877
3878 let mut filter_lock = self
3879 .log_level_filter
3880 .lock()
3881 .unwrap_or_else(|e| e.into_inner());
3882 *filter_lock = level_filter;
3883 Ok(())
3884 }
3885}
3886
3887#[cfg(test)]
3888mod tests {
3889 use super::*;
3890
3891 #[tokio::test]
3892 async fn test_emit_progress_none_peer_is_noop() {
3893 let peer = Arc::new(TokioMutex::new(None));
3894 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3895 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3896 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3897 let analyzer = CodeAnalyzer::new(
3898 peer,
3899 log_level_filter,
3900 rx,
3901 crate::metrics::MetricsSender(metrics_tx),
3902 );
3903 let token = ProgressToken(NumberOrString::String("test".into()));
3904 analyzer
3906 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3907 .await;
3908 }
3909
3910 fn make_analyzer() -> CodeAnalyzer {
3911 let peer = Arc::new(TokioMutex::new(None));
3912 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3913 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3914 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3915 CodeAnalyzer::new(
3916 peer,
3917 log_level_filter,
3918 rx,
3919 crate::metrics::MetricsSender(metrics_tx),
3920 )
3921 }
3922
3923 #[test]
3924 fn test_summary_cursor_conflict() {
3925 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3926 assert!(!summary_cursor_conflict(Some(true), None));
3927 assert!(!summary_cursor_conflict(None, Some("x")));
3928 assert!(!summary_cursor_conflict(None, None));
3929 }
3930
3931 #[tokio::test]
3932 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3933 use tempfile::TempDir;
3934
3935 let dir = TempDir::new().unwrap();
3936 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3937
3938 let analyzer = make_analyzer();
3939 let entries: Vec<traversal::WalkEntry> =
3942 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3943 let result = CodeAnalyzer::validate_impl_only(&entries);
3944 assert!(result.is_err());
3945 let err = result.unwrap_err();
3946 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3947 drop(analyzer); }
3949
3950 #[tokio::test]
3951 async fn test_no_cache_meta_on_analyze_directory_result() {
3952 use aptu_coder_core::types::{
3953 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3954 };
3955 use tempfile::TempDir;
3956
3957 let dir = TempDir::new().unwrap();
3958 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3959
3960 let analyzer = make_analyzer();
3961 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3962 "path": dir.path().to_str().unwrap(),
3963 }))
3964 .unwrap();
3965 let ct = tokio_util::sync::CancellationToken::new();
3966 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3967 let meta = no_cache_meta();
3969 assert_eq!(
3970 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3971 Some("no-cache"),
3972 );
3973 drop(arc_output);
3974 }
3975
3976 #[test]
3977 fn test_complete_path_completions_returns_suggestions() {
3978 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3983 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3984 let suggestions = completion::path_completions(workspace_root, "aptu-");
3985 assert!(
3986 !suggestions.is_empty(),
3987 "expected completions for prefix 'aptu-' in workspace root"
3988 );
3989 }
3990
3991 #[tokio::test]
3992 async fn test_handle_overview_mode_verbose_no_summary_block() {
3993 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3994 use aptu_coder_core::types::{
3995 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3996 };
3997 use tempfile::TempDir;
3998
3999 let tmp = TempDir::new().unwrap();
4000 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4001
4002 let peer = Arc::new(TokioMutex::new(None));
4003 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4004 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4005 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4006 let analyzer = CodeAnalyzer::new(
4007 peer,
4008 log_level_filter,
4009 rx,
4010 crate::metrics::MetricsSender(metrics_tx),
4011 );
4012
4013 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4014 "path": tmp.path().to_str().unwrap(),
4015 "verbose": true,
4016 }))
4017 .unwrap();
4018
4019 let ct = tokio_util::sync::CancellationToken::new();
4020 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4021
4022 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
4025 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4026 let verbose = true;
4027 let formatted = if !use_summary {
4028 format_structure_paginated(
4029 &paginated.items,
4030 paginated.total,
4031 params.max_depth,
4032 Some(std::path::Path::new(¶ms.path)),
4033 verbose,
4034 )
4035 } else {
4036 output.formatted.clone()
4037 };
4038
4039 assert!(
4041 !formatted.contains("SUMMARY:"),
4042 "verbose=true must not emit SUMMARY: block; got: {}",
4043 &formatted[..formatted.len().min(300)]
4044 );
4045 assert!(
4046 formatted.contains("PAGINATED:"),
4047 "verbose=true must emit PAGINATED: header"
4048 );
4049 assert!(
4050 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4051 "verbose=true must emit FILES section header"
4052 );
4053 }
4054
4055 #[tokio::test]
4058 async fn test_analyze_directory_cache_hit_metrics() {
4059 use aptu_coder_core::types::{
4060 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4061 };
4062 use tempfile::TempDir;
4063
4064 let dir = TempDir::new().unwrap();
4066 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4067 let analyzer = make_analyzer();
4068 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4069 "path": dir.path().to_str().unwrap(),
4070 }))
4071 .unwrap();
4072
4073 let ct1 = tokio_util::sync::CancellationToken::new();
4075 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4076
4077 let ct2 = tokio_util::sync::CancellationToken::new();
4079 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4080
4081 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4083 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4084 }
4085
4086 #[tokio::test]
4087 async fn test_analyze_module_cache_hit_metrics() {
4088 use std::io::Write as _;
4089 use tempfile::NamedTempFile;
4090
4091 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4093 writeln!(f, "fn bar() {{}}").unwrap();
4094 let path = f.path().to_str().unwrap().to_string();
4095
4096 let analyzer = make_analyzer();
4097
4098 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4100 file_params.path = path.clone();
4101 file_params.ast_recursion_limit = None;
4102 file_params.fields = None;
4103 file_params.pagination.cursor = None;
4104 file_params.pagination.page_size = None;
4105 file_params.output_control.summary = None;
4106 file_params.output_control.force = None;
4107 file_params.output_control.verbose = None;
4108 let (_cached, _) = analyzer
4109 .handle_file_details_mode(&file_params)
4110 .await
4111 .unwrap();
4112
4113 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4115 module_params.path = path.clone();
4116
4117 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4119 meta.modified()
4120 .ok()
4121 .map(|mtime| aptu_coder_core::cache::CacheKey {
4122 path: std::path::PathBuf::from(&path),
4123 modified: mtime,
4124 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4125 })
4126 });
4127 let cache_hit = module_cache_key
4128 .as_ref()
4129 .and_then(|k| analyzer.cache.get(k))
4130 .is_some();
4131
4132 assert!(
4134 cache_hit,
4135 "analyze_module should find the file in the shared file cache"
4136 );
4137 drop(module_params);
4138 }
4139
4140 #[test]
4143 fn test_analyze_symbol_import_lookup_invalid_params() {
4144 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4148
4149 assert!(
4151 result.is_err(),
4152 "import_lookup=true with empty symbol must return Err"
4153 );
4154 let err = result.unwrap_err();
4155 assert_eq!(
4156 err.code,
4157 rmcp::model::ErrorCode::INVALID_PARAMS,
4158 "expected INVALID_PARAMS; got {:?}",
4159 err.code
4160 );
4161 }
4162
4163 #[tokio::test]
4164 async fn test_analyze_symbol_import_lookup_found() {
4165 use tempfile::TempDir;
4166
4167 let dir = TempDir::new().unwrap();
4169 std::fs::write(
4170 dir.path().join("main.rs"),
4171 "use std::collections::HashMap;\nfn main() {}\n",
4172 )
4173 .unwrap();
4174
4175 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4176
4177 let output =
4179 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4180
4181 assert!(
4183 output.formatted.contains("MATCHES: 1"),
4184 "expected 1 match; got: {}",
4185 output.formatted
4186 );
4187 assert!(
4188 output.formatted.contains("main.rs"),
4189 "expected main.rs in output; got: {}",
4190 output.formatted
4191 );
4192 }
4193
4194 #[tokio::test]
4195 async fn test_analyze_symbol_import_lookup_empty() {
4196 use tempfile::TempDir;
4197
4198 let dir = TempDir::new().unwrap();
4200 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4201
4202 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4203
4204 let output =
4206 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4207
4208 assert!(
4210 output.formatted.contains("MATCHES: 0"),
4211 "expected 0 matches; got: {}",
4212 output.formatted
4213 );
4214 }
4215
4216 #[tokio::test]
4219 async fn test_analyze_directory_git_ref_non_git_repo() {
4220 use aptu_coder_core::traversal::changed_files_from_git_ref;
4221 use tempfile::TempDir;
4222
4223 let dir = TempDir::new().unwrap();
4225 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4226
4227 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4229
4230 assert!(result.is_err(), "non-git dir must return an error");
4232 let err_msg = result.unwrap_err().to_string();
4233 assert!(
4234 err_msg.contains("git"),
4235 "error must mention git; got: {err_msg}"
4236 );
4237 }
4238
4239 #[tokio::test]
4240 async fn test_analyze_directory_git_ref_filters_changed_files() {
4241 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4242 use std::collections::HashSet;
4243 use tempfile::TempDir;
4244
4245 let dir = TempDir::new().unwrap();
4247 let changed_file = dir.path().join("changed.rs");
4248 let unchanged_file = dir.path().join("unchanged.rs");
4249 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4250 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4251
4252 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4253 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4254 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4255
4256 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4258 changed.insert(changed_file.clone());
4259
4260 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4262 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4263
4264 assert_eq!(
4266 filtered_files.len(),
4267 1,
4268 "only 1 file must remain after git_ref filter"
4269 );
4270 assert_eq!(
4271 filtered_files[0].path, changed_file,
4272 "the remaining file must be the changed one"
4273 );
4274
4275 let _ = changed_files_from_git_ref;
4277 }
4278
4279 #[tokio::test]
4280 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4281 use aptu_coder_core::types::{
4282 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4283 };
4284 use std::process::Command;
4285 use tempfile::TempDir;
4286
4287 let dir = TempDir::new().unwrap();
4289 let repo = dir.path();
4290
4291 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4294 let mut cmd = std::process::Command::new("git");
4295 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4296 cmd.args(args);
4297 cmd.current_dir(repo_path);
4298 let out = cmd.output().unwrap();
4299 assert!(out.status.success(), "{out:?}");
4300 };
4301 git_no_hook(repo, &["init"]);
4302 git_no_hook(
4303 repo,
4304 &[
4305 "-c",
4306 "user.email=ci@example.com",
4307 "-c",
4308 "user.name=CI",
4309 "commit",
4310 "--allow-empty",
4311 "-m",
4312 "initial",
4313 ],
4314 );
4315
4316 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4318 git_no_hook(repo, &["add", "file_a.rs"]);
4319 git_no_hook(
4320 repo,
4321 &[
4322 "-c",
4323 "user.email=ci@example.com",
4324 "-c",
4325 "user.name=CI",
4326 "commit",
4327 "-m",
4328 "add a",
4329 ],
4330 );
4331
4332 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4334 git_no_hook(repo, &["add", "file_b.rs"]);
4335 git_no_hook(
4336 repo,
4337 &[
4338 "-c",
4339 "user.email=ci@example.com",
4340 "-c",
4341 "user.name=CI",
4342 "commit",
4343 "-m",
4344 "add b",
4345 ],
4346 );
4347
4348 let canon_repo = std::fs::canonicalize(repo).unwrap();
4354 let analyzer = make_analyzer();
4355 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4356 "path": canon_repo.to_str().unwrap(),
4357 "git_ref": "HEAD~1",
4358 }))
4359 .unwrap();
4360 let ct = tokio_util::sync::CancellationToken::new();
4361 let (arc_output, _cache_hit) = analyzer
4362 .handle_overview_mode(¶ms, ct)
4363 .await
4364 .expect("handle_overview_mode with git_ref must succeed");
4365
4366 let formatted = &arc_output.formatted;
4368 assert!(
4369 formatted.contains("file_b.rs"),
4370 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4371 );
4372 assert!(
4373 !formatted.contains("file_a.rs"),
4374 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4375 );
4376 }
4377
4378 #[test]
4379 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4380 let result = validate_path("/etc/passwd", true);
4383 assert!(
4384 result.is_err(),
4385 "validate_path should reject /etc/passwd (outside CWD)"
4386 );
4387 let err = result.unwrap_err();
4388 let err_msg = err.message.to_lowercase();
4389 assert!(
4390 err_msg.contains("outside") || err_msg.contains("not found"),
4391 "Error message should mention 'outside' or 'not found': {}",
4392 err.message
4393 );
4394 }
4395
4396 #[test]
4397 fn test_validate_path_accepts_relative_path_in_cwd() {
4398 let result = validate_path("Cargo.toml", true);
4401 assert!(
4402 result.is_ok(),
4403 "validate_path should accept Cargo.toml (exists in CWD)"
4404 );
4405 }
4406
4407 #[test]
4408 fn test_validate_path_creates_parent_for_nonexistent_file() {
4409 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4412 assert!(
4413 result.is_ok(),
4414 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4415 );
4416 let path = result.unwrap();
4417 let cwd = std::env::current_dir().expect("should get cwd");
4418 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4419 assert!(
4420 path.starts_with(&canonical_cwd),
4421 "Resolved path should be within CWD: {:?} should start with {:?}",
4422 path,
4423 canonical_cwd
4424 );
4425 }
4426
4427 #[test]
4428 fn test_edit_overwrite_with_working_dir() {
4429 let cwd = std::env::current_dir().expect("should get cwd");
4431 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4432 let temp_path = temp_dir.path();
4433
4434 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4436
4437 assert!(
4439 result.is_ok(),
4440 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4441 result.err()
4442 );
4443 let resolved = result.unwrap();
4444 assert!(
4445 resolved.starts_with(temp_path),
4446 "Resolved path should be within working_dir: {:?} should start with {:?}",
4447 resolved,
4448 temp_path
4449 );
4450 }
4451
4452 #[test]
4453 fn test_edit_overwrite_working_dir_traversal() {
4454 let cwd = std::env::current_dir().expect("should get cwd");
4456 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4457 let temp_path = temp_dir.path();
4458
4459 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4461
4462 assert!(
4464 result.is_err(),
4465 "validate_path_in_dir should reject path traversal outside working_dir"
4466 );
4467 let err = result.unwrap_err();
4468 let err_msg = err.message.to_lowercase();
4469 assert!(
4470 err_msg.contains("outside") || err_msg.contains("working"),
4471 "Error message should mention 'outside' or 'working': {}",
4472 err.message
4473 );
4474 }
4475
4476 #[test]
4477 fn test_edit_replace_with_working_dir() {
4478 let cwd = std::env::current_dir().expect("should get cwd");
4480 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4481 let temp_path = temp_dir.path();
4482 let file_path = temp_path.join("test.txt");
4483 std::fs::write(&file_path, "hello world").expect("should write test file");
4484
4485 let result = validate_path_in_dir("test.txt", true, temp_path);
4487
4488 assert!(
4490 result.is_ok(),
4491 "validate_path_in_dir should find existing file in working_dir: {:?}",
4492 result.err()
4493 );
4494 let resolved = result.unwrap();
4495 assert_eq!(
4496 resolved, file_path,
4497 "Resolved path should match the actual file path"
4498 );
4499 }
4500
4501 #[test]
4502 fn test_edit_overwrite_no_working_dir() {
4503 let result = validate_path("Cargo.toml", true);
4508
4509 assert!(
4511 result.is_ok(),
4512 "validate_path should still work without working_dir"
4513 );
4514 }
4515
4516 #[test]
4517 fn test_edit_overwrite_working_dir_is_file() {
4518 let cwd = std::env::current_dir().expect("should get cwd");
4520 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4521 let temp_file = temp_dir.path().join("test_file.txt");
4522 std::fs::write(&temp_file, "test content").expect("should write test file");
4523
4524 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4526
4527 assert!(
4529 result.is_err(),
4530 "validate_path_in_dir should reject a file as working_dir"
4531 );
4532 let err = result.unwrap_err();
4533 let err_msg = err.message.to_lowercase();
4534 assert!(
4535 err_msg.contains("directory"),
4536 "Error message should mention 'directory': {}",
4537 err.message
4538 );
4539 }
4540
4541 #[test]
4542 fn test_tool_annotations() {
4543 let tools = CodeAnalyzer::list_tools();
4545
4546 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4548 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4549
4550 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4552 let analyze_dir_annot = analyze_dir_tool
4553 .annotations
4554 .as_ref()
4555 .expect("analyze_directory should have annotations");
4556 assert_eq!(
4557 analyze_dir_annot.read_only_hint,
4558 Some(true),
4559 "analyze_directory read_only_hint should be true"
4560 );
4561 assert_eq!(
4562 analyze_dir_annot.destructive_hint,
4563 Some(false),
4564 "analyze_directory destructive_hint should be false"
4565 );
4566
4567 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4569 let exec_cmd_annot = exec_cmd_tool
4570 .annotations
4571 .as_ref()
4572 .expect("exec_command should have annotations");
4573 assert_eq!(
4574 exec_cmd_annot.open_world_hint,
4575 Some(true),
4576 "exec_command open_world_hint should be true"
4577 );
4578 }
4579
4580 #[test]
4581 fn test_exec_stdin_size_cap_validation() {
4582 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4585
4586 assert!(
4588 oversized_stdin.len() > STDIN_MAX_BYTES,
4589 "test setup: oversized stdin should exceed 1 MB"
4590 );
4591
4592 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4594 assert_eq!(
4595 max_stdin.len(),
4596 STDIN_MAX_BYTES,
4597 "test setup: max stdin should be exactly 1 MB"
4598 );
4599 }
4600
4601 #[tokio::test]
4602 async fn test_exec_stdin_cat_roundtrip() {
4603 let stdin_content = "hello world";
4606
4607 let mut child = tokio::process::Command::new("sh")
4609 .arg("-c")
4610 .arg("cat")
4611 .stdin(std::process::Stdio::piped())
4612 .stdout(std::process::Stdio::piped())
4613 .stderr(std::process::Stdio::piped())
4614 .spawn()
4615 .expect("spawn cat");
4616
4617 if let Some(mut stdin_handle) = child.stdin.take() {
4618 use tokio::io::AsyncWriteExt as _;
4619 stdin_handle
4620 .write_all(stdin_content.as_bytes())
4621 .await
4622 .expect("write stdin");
4623 drop(stdin_handle);
4624 }
4625
4626 let output = child.wait_with_output().await.expect("wait for cat");
4627
4628 let stdout_str = String::from_utf8_lossy(&output.stdout);
4630 assert!(
4631 stdout_str.contains(stdin_content),
4632 "stdout should contain stdin content: {}",
4633 stdout_str
4634 );
4635 }
4636
4637 #[tokio::test]
4638 async fn test_exec_stdin_none_no_regression() {
4639 let child = tokio::process::Command::new("sh")
4642 .arg("-c")
4643 .arg("echo hi")
4644 .stdin(std::process::Stdio::null())
4645 .stdout(std::process::Stdio::piped())
4646 .stderr(std::process::Stdio::piped())
4647 .spawn()
4648 .expect("spawn echo");
4649
4650 let output = child.wait_with_output().await.expect("wait for echo");
4651
4652 let stdout_str = String::from_utf8_lossy(&output.stdout);
4654 assert!(
4655 stdout_str.contains("hi"),
4656 "stdout should contain echo output: {}",
4657 stdout_str
4658 );
4659 }
4660
4661 #[test]
4662 fn test_validate_path_in_dir_rejects_sibling_prefix() {
4663 let cwd = std::env::current_dir().expect("should get cwd");
4668 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4669 let allowed = parent.path().join("allowed");
4670 let sibling = parent.path().join("allowed_sibling");
4671 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4672 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4673
4674 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4677
4678 assert!(
4680 result.is_err(),
4681 "validate_path_in_dir must reject a path resolving to a sibling directory \
4682 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4683 );
4684 let err = result.unwrap_err();
4685 let msg = err.message.to_lowercase();
4686 assert!(
4687 msg.contains("outside") || msg.contains("working"),
4688 "Error should mention 'outside' or 'working', got: {}",
4689 err.message
4690 );
4691 }
4692
4693 #[test]
4694 #[serial_test::serial]
4695 fn test_file_cache_capacity_default() {
4696 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4698
4699 let analyzer = make_analyzer();
4701
4702 assert_eq!(analyzer.cache.file_capacity(), 100);
4704 }
4705
4706 #[test]
4707 #[serial_test::serial]
4708 fn test_file_cache_capacity_from_env() {
4709 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
4711
4712 let analyzer = make_analyzer();
4714
4715 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4717
4718 assert_eq!(analyzer.cache.file_capacity(), 42);
4720 }
4721
4722 #[test]
4723 fn test_exec_command_path_injected() {
4724 let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
4726 let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
4727
4728 let cmd_str = format!("{:?}", cmd);
4732
4733 assert!(
4735 !cmd_str.is_empty(),
4736 "build_exec_command should return a valid Command"
4737 );
4738 }
4739
4740 #[test]
4741 fn test_exec_command_path_fallback() {
4742 let cmd = build_exec_command("echo test", None, None, None, false, None);
4744
4745 let cmd_str = format!("{:?}", cmd);
4747
4748 assert!(
4750 !cmd_str.is_empty(),
4751 "build_exec_command should handle None resolved_path gracefully"
4752 );
4753 }
4754
4755 #[test]
4756 fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
4757 assert_eq!(
4761 CacheTier::Miss.as_str(),
4762 "miss",
4763 "CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
4764 );
4765 assert!(
4766 !matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
4767 "CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
4768 );
4769 }
4770}