1pub mod logging;
19pub mod metrics;
20pub mod otel;
21
22pub use aptu_coder_core::analyze;
23use aptu_coder_core::types::STDIN_MAX_BYTES;
24use aptu_coder_core::{cache, completion, graph, traversal, types};
25
26pub(crate) const EXCLUDED_DIRS: &[&str] = &[
27 "node_modules",
28 "vendor",
29 ".git",
30 "__pycache__",
31 "target",
32 "dist",
33 "build",
34 ".venv",
35];
36
37use aptu_coder_core::cache::AnalysisCache;
38use aptu_coder_core::formatter::{
39 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
40 format_module_info, format_structure_paginated, format_summary,
41};
42use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
43use aptu_coder_core::pagination::{
44 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
45};
46use aptu_coder_core::traversal::{
47 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
48};
49use aptu_coder_core::types::{
50 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
51 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
52 EditReplaceParams, SymbolMatchMode,
53};
54use logging::LogEvent;
55use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
56use rmcp::handler::server::wrapper::Parameters;
57use rmcp::model::{
58 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
59 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
60 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
61 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
62 SetLevelRequestParams,
63};
64use rmcp::service::{NotificationContext, RequestContext};
65use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
66use serde_json::Value;
67use std::path::{Path, PathBuf};
68use std::sync::{Arc, Mutex};
69use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
70use tracing::{instrument, warn};
71use tracing_subscriber::filter::LevelFilter;
72
73#[cfg(unix)]
74use nix::sys::resource::{Resource, setrlimit};
75
76static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
77
78const SIZE_LIMIT: usize = 50_000;
79
80#[must_use]
83pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
84 summary == Some(true) && cursor.is_some()
85}
86
87pub fn extract_and_set_trace_context(meta: Option<&rmcp::model::Meta>) {
95 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
96
97 let Some(meta) = meta else { return };
98
99 let mut propagation_map = std::collections::HashMap::new();
100
101 if let Some(traceparent) = meta.0.get("traceparent")
103 && let Some(tp_str) = traceparent.as_str()
104 {
105 propagation_map.insert("traceparent".to_string(), tp_str.to_string());
106 }
107
108 if let Some(tracestate) = meta.0.get("tracestate")
110 && let Some(ts_str) = tracestate.as_str()
111 {
112 propagation_map.insert("tracestate".to_string(), ts_str.to_string());
113 }
114
115 if propagation_map.is_empty() {
117 return;
118 }
119
120 let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
122 propagator.extract(&ExtractMap(&propagation_map))
123 });
124
125 let _ = tracing::Span::current().set_parent(parent_cx);
128}
129
130struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
132
133impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
134 fn get(&self, key: &str) -> Option<&str> {
135 self.0.get(key).map(|s| s.as_str())
136 }
137
138 fn keys(&self) -> Vec<&str> {
139 self.0.keys().map(|k| k.as_str()).collect()
140 }
141}
142
143#[must_use]
144fn error_meta(
145 category: &'static str,
146 is_retryable: bool,
147 suggested_action: &'static str,
148) -> serde_json::Value {
149 serde_json::json!({
150 "errorCategory": category,
151 "isRetryable": is_retryable,
152 "suggestedAction": suggested_action,
153 })
154}
155
156#[must_use]
157fn err_to_tool_result(e: ErrorData) -> CallToolResult {
158 CallToolResult::error(vec![Content::text(e.message)])
159}
160
161fn err_to_tool_result_from_pagination(
162 e: aptu_coder_core::pagination::PaginationError,
163) -> CallToolResult {
164 let msg = format!("Pagination error: {}", e);
165 CallToolResult::error(vec![Content::text(msg)])
166}
167
168fn no_cache_meta() -> Meta {
169 let mut m = serde_json::Map::new();
170 m.insert(
171 "cache_hint".to_string(),
172 serde_json::Value::String("no-cache".to_string()),
173 );
174 Meta(m)
175}
176
177fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
181 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
183 ErrorData::new(
184 rmcp::model::ErrorCode::INVALID_PARAMS,
185 "path is outside the allowed root".to_string(),
186 Some(error_meta(
187 "validation",
188 false,
189 "ensure the working directory is accessible",
190 )),
191 )
192 })?)
193 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
194
195 let canonical_path = if require_exists {
196 std::fs::canonicalize(path).map_err(|e| {
197 let msg = match e.kind() {
198 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
199 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
200 _ => "path is outside the allowed root".to_string(),
201 };
202 ErrorData::new(
203 rmcp::model::ErrorCode::INVALID_PARAMS,
204 msg,
205 Some(error_meta(
206 "validation",
207 false,
208 "provide a valid path within the working directory",
209 )),
210 )
211 })?
212 } else {
213 let p = std::path::Path::new(path);
215 let mut ancestor = p.to_path_buf();
216 let mut suffix = std::path::PathBuf::new();
217
218 loop {
219 if ancestor.exists() {
220 break;
221 }
222 if let Some(parent) = ancestor.parent() {
223 if let Some(file_name) = ancestor.file_name() {
224 suffix = std::path::PathBuf::from(file_name).join(&suffix);
225 }
226 ancestor = parent.to_path_buf();
227 } else {
228 ancestor = allowed_root.clone();
230 break;
231 }
232 }
233
234 let canonical_base =
235 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
236 canonical_base.join(&suffix)
237 };
238
239 if !canonical_path.starts_with(&allowed_root) {
240 return Err(ErrorData::new(
241 rmcp::model::ErrorCode::INVALID_PARAMS,
242 "path is outside the allowed root".to_string(),
243 Some(error_meta(
244 "validation",
245 false,
246 "provide a path within the current working directory",
247 )),
248 ));
249 }
250
251 Ok(canonical_path)
252}
253
254fn io_error_to_path_error(
256 err: &std::io::Error,
257 path_context: &str,
258 suggested_action: &'static str,
259) -> ErrorData {
260 let msg = match err.kind() {
261 std::io::ErrorKind::NotFound => format!("{path_context} not found"),
262 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
263 _ => format!("{path_context} is invalid"),
264 };
265 let mut meta = error_meta("validation", false, suggested_action);
266 if let Some(obj) = meta.as_object_mut() {
268 obj.insert(
269 "ioErrorKind".to_string(),
270 serde_json::json!(format!("{:?}", err.kind())),
271 );
272 obj.insert(
273 "ioErrorSource".to_string(),
274 serde_json::json!(err.to_string()),
275 );
276 }
277 ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
278}
279
280fn validate_path_in_dir(
284 path: &str,
285 require_exists: bool,
286 working_dir: &std::path::Path,
287) -> Result<std::path::PathBuf, ErrorData> {
288 let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
290 io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
291 })?;
292
293 if !std::fs::metadata(&canonical_working_dir)
295 .map(|m| m.is_dir())
296 .unwrap_or(false)
297 {
298 return Err(ErrorData::new(
299 rmcp::model::ErrorCode::INVALID_PARAMS,
300 "working_dir must be a directory".to_string(),
301 Some(error_meta(
302 "validation",
303 false,
304 "provide a valid directory path",
305 )),
306 ));
307 }
308
309 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
311 ErrorData::new(
312 rmcp::model::ErrorCode::INVALID_PARAMS,
313 "path is outside the allowed root".to_string(),
314 Some(error_meta(
315 "validation",
316 false,
317 "ensure the working directory is accessible",
318 )),
319 )
320 })?)
321 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
322
323 if !canonical_working_dir.starts_with(&allowed_root) {
324 return Err(ErrorData::new(
325 rmcp::model::ErrorCode::INVALID_PARAMS,
326 "working_dir is outside the allowed root".to_string(),
327 Some(error_meta(
328 "validation",
329 false,
330 "provide a working directory within the current working directory",
331 )),
332 ));
333 }
334
335 let canonical_path = if require_exists {
337 let target_path = canonical_working_dir.join(path);
338 std::fs::canonicalize(&target_path).map_err(|e| {
339 io_error_to_path_error(
340 &e,
341 path,
342 "provide a valid path within the working directory",
343 )
344 })?
345 } else {
346 let p = std::path::Path::new(path);
348 let mut ancestor = p.to_path_buf();
349 let mut suffix = std::path::PathBuf::new();
350
351 loop {
352 let full_path = canonical_working_dir.join(&ancestor);
353 if full_path.exists() {
354 break;
355 }
356 if let Some(parent) = ancestor.parent() {
357 if let Some(file_name) = ancestor.file_name() {
358 suffix = std::path::PathBuf::from(file_name).join(&suffix);
359 }
360 ancestor = parent.to_path_buf();
361 } else {
362 ancestor = std::path::PathBuf::new();
364 break;
365 }
366 }
367
368 let canonical_base = canonical_working_dir.join(&ancestor);
369 let canonical_base =
370 std::fs::canonicalize(&canonical_base).unwrap_or(canonical_working_dir.clone());
371 canonical_base.join(&suffix)
372 };
373
374 if !canonical_path.starts_with(&canonical_working_dir) {
382 return Err(ErrorData::new(
383 rmcp::model::ErrorCode::INVALID_PARAMS,
384 "path is outside the working directory".to_string(),
385 Some(error_meta(
386 "validation",
387 false,
388 "provide a path within the working directory",
389 )),
390 ));
391 }
392
393 Ok(canonical_path)
394}
395
396fn paginate_focus_chains(
399 chains: &[graph::InternalCallChain],
400 mode: PaginationMode,
401 offset: usize,
402 page_size: usize,
403) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
404 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
405 ErrorData::new(
406 rmcp::model::ErrorCode::INTERNAL_ERROR,
407 e.to_string(),
408 Some(error_meta("transient", true, "retry the request")),
409 )
410 })?;
411
412 if paginated.next_cursor.is_none() && offset == 0 {
413 return Ok((paginated.items, None));
414 }
415
416 let next = if let Some(raw_cursor) = paginated.next_cursor {
417 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
418 ErrorData::new(
419 rmcp::model::ErrorCode::INVALID_PARAMS,
420 e.to_string(),
421 Some(error_meta("validation", false, "invalid cursor format")),
422 )
423 })?;
424 Some(
425 encode_cursor(&CursorData {
426 mode,
427 offset: decoded.offset,
428 })
429 .map_err(|e| {
430 ErrorData::new(
431 rmcp::model::ErrorCode::INVALID_PARAMS,
432 e.to_string(),
433 Some(error_meta("validation", false, "invalid cursor format")),
434 )
435 })?,
436 )
437 } else {
438 None
439 };
440
441 Ok((paginated.items, next))
442}
443
444fn resolve_shell() -> String {
448 if let Ok(shell) = std::env::var("APTU_SHELL") {
449 return shell;
450 }
451 #[cfg(unix)]
452 {
453 if which::which("bash").is_ok() {
454 return "bash".to_string();
455 }
456 "/bin/sh".to_string()
457 }
458 #[cfg(not(unix))]
459 {
460 "cmd".to_string()
461 }
462}
463
464#[derive(Clone)]
469pub struct CodeAnalyzer {
470 #[allow(dead_code)]
478 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
479 cache: AnalysisCache,
480 exec_cache: moka::future::Cache<(String, String), types::ShellOutput>,
481 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
482 log_level_filter: Arc<Mutex<LevelFilter>>,
483 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
484 metrics_tx: crate::metrics::MetricsSender,
485 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
486 session_id: Arc<TokioMutex<Option<String>>>,
487 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
489}
490
491#[tool_router]
492impl CodeAnalyzer {
493 #[must_use]
494 pub fn list_tools() -> Vec<rmcp::model::Tool> {
495 Self::tool_router().list_all()
496 }
497
498 pub fn new(
499 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
500 log_level_filter: Arc<Mutex<LevelFilter>>,
501 event_rx: mpsc::UnboundedReceiver<LogEvent>,
502 metrics_tx: crate::metrics::MetricsSender,
503 ) -> Self {
504 let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
505 .ok()
506 .and_then(|v| v.parse().ok())
507 .unwrap_or(100);
508 let exec_cache_ttl_secs: u64 = std::env::var("APTU_CODER_EXEC_CACHE_TTL_SECS")
509 .ok()
510 .and_then(|v| v.parse().ok())
511 .unwrap_or(10);
512 let exec_cache_capacity: u64 = std::env::var("APTU_CODER_EXEC_CACHE_CAPACITY")
513 .ok()
514 .and_then(|v| v.parse().ok())
515 .unwrap_or(64);
516 let exec_cache = moka::future::Cache::builder()
517 .max_capacity(exec_cache_capacity)
518 .time_to_live(std::time::Duration::from_secs(exec_cache_ttl_secs))
519 .build();
520 CodeAnalyzer {
521 tool_router: Arc::new(RwLock::new(Self::tool_router())),
522 cache: AnalysisCache::new(file_cap),
523 exec_cache,
524 peer,
525 log_level_filter,
526 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
527 metrics_tx,
528 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
529 session_id: Arc::new(TokioMutex::new(None)),
530 profile_meta: Arc::new(TokioMutex::new(None)),
531 }
532 }
533
534 #[instrument(skip(self))]
535 async fn emit_progress(
536 &self,
537 peer: Option<Peer<RoleServer>>,
538 token: &ProgressToken,
539 progress: f64,
540 total: f64,
541 message: String,
542 ) {
543 if let Some(peer) = peer {
544 let notification = ServerNotification::ProgressNotification(Notification::new(
545 ProgressNotificationParam {
546 progress_token: token.clone(),
547 progress,
548 total: Some(total),
549 message: Some(message),
550 },
551 ));
552 if let Err(e) = peer.send_notification(notification).await {
553 warn!("Failed to send progress notification: {}", e);
554 }
555 }
556 }
557
558 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
564 async fn handle_overview_mode(
565 &self,
566 params: &AnalyzeDirectoryParams,
567 ct: tokio_util::sync::CancellationToken,
568 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, bool), ErrorData> {
569 let path = Path::new(¶ms.path);
570 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
571 let counter_clone = counter.clone();
572 let path_owned = path.to_path_buf();
573 let max_depth = params.max_depth;
574 let ct_clone = ct.clone();
575
576 let all_entries = walk_directory(path, None).map_err(|e| {
578 ErrorData::new(
579 rmcp::model::ErrorCode::INTERNAL_ERROR,
580 format!("Failed to walk directory: {e}"),
581 Some(error_meta(
582 "resource",
583 false,
584 "check path permissions and availability",
585 )),
586 )
587 })?;
588
589 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
591
592 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
595 let cache_key = cache::DirectoryCacheKey::from_entries(
596 &all_entries,
597 canonical_max_depth,
598 AnalysisMode::Overview,
599 git_ref_val,
600 );
601
602 if let Some(cached) = self.cache.get_directory(&cache_key) {
604 tracing::debug!(cache_hit = true, message = "returning cached result");
605 return Ok((cached, true));
606 }
607
608 let all_entries = if let Some(ref git_ref) = params.git_ref
610 && !git_ref.is_empty()
611 {
612 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
613 ErrorData::new(
614 rmcp::model::ErrorCode::INVALID_PARAMS,
615 format!("git_ref filter failed: {e}"),
616 Some(error_meta(
617 "resource",
618 false,
619 "ensure git is installed and path is inside a git repository",
620 )),
621 )
622 })?;
623 filter_entries_by_git_ref(all_entries, &changed, path)
624 } else {
625 all_entries
626 };
627
628 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
630 Some(traversal::subtree_counts_from_entries(path, &all_entries))
631 } else {
632 None
633 };
634
635 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
637 && depth > 0
638 {
639 all_entries
640 .into_iter()
641 .filter(|e| e.depth <= depth as usize)
642 .collect()
643 } else {
644 all_entries
645 };
646
647 let total_files = entries.iter().filter(|e| !e.is_dir).count();
649
650 let handle = tokio::task::spawn_blocking(move || {
652 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
653 });
654
655 let token = ProgressToken(NumberOrString::String(
657 format!(
658 "analyze-overview-{}",
659 std::time::SystemTime::now()
660 .duration_since(std::time::UNIX_EPOCH)
661 .map(|d| d.as_nanos())
662 .unwrap_or(0)
663 )
664 .into(),
665 ));
666 let peer = self.peer.lock().await.clone();
667 let mut last_progress = 0usize;
668 let mut cancelled = false;
669 loop {
670 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
671 if ct.is_cancelled() {
672 cancelled = true;
673 break;
674 }
675 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
676 if current != last_progress && total_files > 0 {
677 self.emit_progress(
678 peer.clone(),
679 &token,
680 current as f64,
681 total_files as f64,
682 format!("Analyzing {current}/{total_files} files"),
683 )
684 .await;
685 last_progress = current;
686 }
687 if handle.is_finished() {
688 break;
689 }
690 }
691
692 if !cancelled && total_files > 0 {
694 self.emit_progress(
695 peer.clone(),
696 &token,
697 total_files as f64,
698 total_files as f64,
699 format!("Completed analyzing {total_files} files"),
700 )
701 .await;
702 }
703
704 match handle.await {
705 Ok(Ok(mut output)) => {
706 output.subtree_counts = subtree_counts;
707 let arc_output = std::sync::Arc::new(output);
708 self.cache.put_directory(cache_key, arc_output.clone());
709 Ok((arc_output, false))
710 }
711 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
712 rmcp::model::ErrorCode::INTERNAL_ERROR,
713 "Analysis cancelled".to_string(),
714 Some(error_meta("transient", true, "analysis was cancelled")),
715 )),
716 Ok(Err(e)) => Err(ErrorData::new(
717 rmcp::model::ErrorCode::INTERNAL_ERROR,
718 format!("Error analyzing directory: {e}"),
719 Some(error_meta(
720 "resource",
721 false,
722 "check path and file permissions",
723 )),
724 )),
725 Err(e) => Err(ErrorData::new(
726 rmcp::model::ErrorCode::INTERNAL_ERROR,
727 format!("Task join error: {e}"),
728 Some(error_meta("transient", true, "retry the request")),
729 )),
730 }
731 }
732
733 #[instrument(skip(self, params))]
736 async fn handle_file_details_mode(
737 &self,
738 params: &AnalyzeFileParams,
739 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, bool), ErrorData> {
740 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
742 meta.modified().ok().map(|mtime| cache::CacheKey {
743 path: std::path::PathBuf::from(¶ms.path),
744 modified: mtime,
745 mode: AnalysisMode::FileDetails,
746 })
747 });
748
749 if let Some(ref key) = cache_key
751 && let Some(cached) = self.cache.get(key)
752 {
753 tracing::debug!(cache_hit = true, message = "returning cached result");
754 return Ok((cached, true));
755 }
756
757 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
759 Ok(output) => {
760 let arc_output = std::sync::Arc::new(output);
761 if let Some(key) = cache_key {
762 self.cache.put(key, arc_output.clone());
763 }
764 Ok((arc_output, false))
765 }
766 Err(e) => Err(ErrorData::new(
767 rmcp::model::ErrorCode::INTERNAL_ERROR,
768 format!("Error analyzing file: {e}"),
769 Some(error_meta(
770 "resource",
771 false,
772 "check file path and permissions",
773 )),
774 )),
775 }
776 }
777
778 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
780 let has_rust = entries.iter().any(|e| {
781 !e.is_dir
782 && e.path
783 .extension()
784 .and_then(|x: &std::ffi::OsStr| x.to_str())
785 == Some("rs")
786 });
787
788 if !has_rust {
789 return Err(ErrorData::new(
790 rmcp::model::ErrorCode::INVALID_PARAMS,
791 "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(),
792 Some(error_meta(
793 "validation",
794 false,
795 "remove impl_only or point to a directory containing .rs files",
796 )),
797 ));
798 }
799 Ok(())
800 }
801
802 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
804 if import_lookup == Some(true) && symbol.is_empty() {
805 return Err(ErrorData::new(
806 rmcp::model::ErrorCode::INVALID_PARAMS,
807 "import_lookup=true requires symbol to contain the module path to search for"
808 .to_string(),
809 Some(error_meta(
810 "validation",
811 false,
812 "set symbol to the module path when using import_lookup=true",
813 )),
814 ));
815 }
816 Ok(())
817 }
818
819 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
822 &self,
823 analysis_params: &FocusedAnalysisParams,
824 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
825 ct: tokio_util::sync::CancellationToken,
826 entries: std::sync::Arc<Vec<WalkEntry>>,
827 total_files: usize,
828 symbol_display: &str,
829 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
830 let counter_clone = counter.clone();
831 let ct_clone = ct.clone();
832 let entries_clone = std::sync::Arc::clone(&entries);
833 let path_owned = analysis_params.path.clone();
834 let symbol_owned = analysis_params.symbol.clone();
835 let match_mode_owned = analysis_params.match_mode.clone();
836 let follow_depth = analysis_params.follow_depth;
837 let max_depth = analysis_params.max_depth;
838 let ast_recursion_limit = analysis_params.ast_recursion_limit;
839 let use_summary = analysis_params.use_summary;
840 let impl_only = analysis_params.impl_only;
841 let def_use = analysis_params.def_use;
842 let parse_timeout_micros = analysis_params.parse_timeout_micros;
843 let handle = tokio::task::spawn_blocking(move || {
844 let params = analyze::FocusedAnalysisConfig {
845 focus: symbol_owned,
846 match_mode: match_mode_owned,
847 follow_depth,
848 max_depth,
849 ast_recursion_limit,
850 use_summary,
851 impl_only,
852 def_use,
853 parse_timeout_micros,
854 };
855 analyze::analyze_focused_with_progress_with_entries(
856 &path_owned,
857 ¶ms,
858 &counter_clone,
859 &ct_clone,
860 &entries_clone,
861 )
862 });
863
864 let token = ProgressToken(NumberOrString::String(
865 format!(
866 "analyze-symbol-{}",
867 std::time::SystemTime::now()
868 .duration_since(std::time::UNIX_EPOCH)
869 .map(|d| d.as_nanos())
870 .unwrap_or(0)
871 )
872 .into(),
873 ));
874 let peer = self.peer.lock().await.clone();
875 let mut last_progress = 0usize;
876 let mut cancelled = false;
877
878 loop {
879 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
880 if ct.is_cancelled() {
881 cancelled = true;
882 break;
883 }
884 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
885 if current != last_progress && total_files > 0 {
886 self.emit_progress(
887 peer.clone(),
888 &token,
889 current as f64,
890 total_files as f64,
891 format!(
892 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
893 ),
894 )
895 .await;
896 last_progress = current;
897 }
898 if handle.is_finished() {
899 break;
900 }
901 }
902
903 if !cancelled && total_files > 0 {
904 self.emit_progress(
905 peer.clone(),
906 &token,
907 total_files as f64,
908 total_files as f64,
909 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
910 )
911 .await;
912 }
913
914 match handle.await {
915 Ok(Ok(output)) => Ok(output),
916 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
917 rmcp::model::ErrorCode::INTERNAL_ERROR,
918 "Analysis cancelled".to_string(),
919 Some(error_meta("transient", true, "analysis was cancelled")),
920 )),
921 Ok(Err(e)) => Err(ErrorData::new(
922 rmcp::model::ErrorCode::INTERNAL_ERROR,
923 format!("Error analyzing symbol: {e}"),
924 Some(error_meta("resource", false, "check symbol name and file")),
925 )),
926 Err(e) => Err(ErrorData::new(
927 rmcp::model::ErrorCode::INTERNAL_ERROR,
928 format!("Task join error: {e}"),
929 Some(error_meta("transient", true, "retry the request")),
930 )),
931 }
932 }
933
934 async fn run_focused_with_auto_summary(
936 &self,
937 params: &AnalyzeSymbolParams,
938 analysis_params: &FocusedAnalysisParams,
939 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
940 ct: tokio_util::sync::CancellationToken,
941 entries: std::sync::Arc<Vec<WalkEntry>>,
942 total_files: usize,
943 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
944 let use_summary_for_task = params.output_control.force != Some(true)
945 && params.output_control.summary == Some(true);
946
947 let analysis_params_initial = FocusedAnalysisParams {
948 use_summary: use_summary_for_task,
949 ..analysis_params.clone()
950 };
951
952 let mut output = self
953 .poll_progress_until_done(
954 &analysis_params_initial,
955 counter.clone(),
956 ct.clone(),
957 entries.clone(),
958 total_files,
959 ¶ms.symbol,
960 )
961 .await?;
962
963 if params.output_control.summary.is_none()
964 && params.output_control.force != Some(true)
965 && output.formatted.len() > SIZE_LIMIT
966 {
967 tracing::debug!(
968 auto_summary = true,
969 message = "output exceeded size limit, retrying with summary"
970 );
971 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
972 let analysis_params_retry = FocusedAnalysisParams {
973 use_summary: true,
974 ..analysis_params.clone()
975 };
976 let summary_result = self
977 .poll_progress_until_done(
978 &analysis_params_retry,
979 counter2,
980 ct,
981 entries,
982 total_files,
983 ¶ms.symbol,
984 )
985 .await;
986
987 if let Ok(summary_output) = summary_result {
988 output.formatted = summary_output.formatted;
989 } else {
990 let estimated_tokens = output.formatted.len() / 4;
991 let message = format!(
992 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
993 output.formatted.len(),
994 estimated_tokens
995 );
996 return Err(ErrorData::new(
997 rmcp::model::ErrorCode::INVALID_PARAMS,
998 message,
999 Some(error_meta(
1000 "validation",
1001 false,
1002 "use summary=true or force=true",
1003 )),
1004 ));
1005 }
1006 } else if output.formatted.len() > SIZE_LIMIT
1007 && params.output_control.force != Some(true)
1008 && params.output_control.summary == Some(false)
1009 {
1010 let estimated_tokens = output.formatted.len() / 4;
1011 let message = format!(
1012 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1013 - force=true to return full output\n\
1014 - summary=true to get compact summary\n\
1015 - Narrow your scope (smaller directory, specific file)",
1016 output.formatted.len(),
1017 estimated_tokens
1018 );
1019 return Err(ErrorData::new(
1020 rmcp::model::ErrorCode::INVALID_PARAMS,
1021 message,
1022 Some(error_meta(
1023 "validation",
1024 false,
1025 "use force=true, summary=true, or narrow scope",
1026 )),
1027 ));
1028 }
1029
1030 Ok(output)
1031 }
1032
1033 #[instrument(skip(self, params, ct))]
1037 async fn handle_focused_mode(
1038 &self,
1039 params: &AnalyzeSymbolParams,
1040 ct: tokio_util::sync::CancellationToken,
1041 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1042 let path = Path::new(¶ms.path);
1043 let raw_entries = match walk_directory(path, params.max_depth) {
1044 Ok(e) => e,
1045 Err(e) => {
1046 return Err(ErrorData::new(
1047 rmcp::model::ErrorCode::INTERNAL_ERROR,
1048 format!("Failed to walk directory: {e}"),
1049 Some(error_meta(
1050 "resource",
1051 false,
1052 "check path permissions and availability",
1053 )),
1054 ));
1055 }
1056 };
1057 let filtered_entries = if let Some(ref git_ref) = params.git_ref
1059 && !git_ref.is_empty()
1060 {
1061 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1062 ErrorData::new(
1063 rmcp::model::ErrorCode::INVALID_PARAMS,
1064 format!("git_ref filter failed: {e}"),
1065 Some(error_meta(
1066 "resource",
1067 false,
1068 "ensure git is installed and path is inside a git repository",
1069 )),
1070 )
1071 })?;
1072 filter_entries_by_git_ref(raw_entries, &changed, path)
1073 } else {
1074 raw_entries
1075 };
1076 let entries = std::sync::Arc::new(filtered_entries);
1077
1078 if params.impl_only == Some(true) {
1079 Self::validate_impl_only(&entries)?;
1080 }
1081
1082 let total_files = entries.iter().filter(|e| !e.is_dir).count();
1083 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1084
1085 let analysis_params = FocusedAnalysisParams {
1086 path: path.to_path_buf(),
1087 symbol: params.symbol.clone(),
1088 match_mode: params.match_mode.clone().unwrap_or_default(),
1089 follow_depth: params.follow_depth.unwrap_or(1),
1090 max_depth: params.max_depth,
1091 ast_recursion_limit: params.ast_recursion_limit,
1092 use_summary: false,
1093 impl_only: params.impl_only,
1094 def_use: params.def_use.unwrap_or(false),
1095 parse_timeout_micros: None,
1096 };
1097
1098 let mut output = self
1099 .run_focused_with_auto_summary(
1100 params,
1101 &analysis_params,
1102 counter,
1103 ct,
1104 entries,
1105 total_files,
1106 )
1107 .await?;
1108
1109 if params.impl_only == Some(true) {
1110 let filter_line = format!(
1111 "FILTER: impl_only=true ({} of {} callers shown)\n",
1112 output.impl_trait_caller_count, output.unfiltered_caller_count
1113 );
1114 output.formatted = format!("{}{}", filter_line, output.formatted);
1115
1116 if output.impl_trait_caller_count == 0 {
1117 output.formatted.push_str(
1118 "\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"
1119 );
1120 }
1121 }
1122
1123 Ok(output)
1124 }
1125
1126 #[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))]
1127 #[tool(
1128 name = "analyze_directory",
1129 title = "Analyze Directory",
1130 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?",
1131 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1132 annotations(
1133 title = "Analyze Directory",
1134 read_only_hint = true,
1135 destructive_hint = false,
1136 idempotent_hint = true,
1137 open_world_hint = false
1138 )
1139 )]
1140 async fn analyze_directory(
1141 &self,
1142 params: Parameters<AnalyzeDirectoryParams>,
1143 context: RequestContext<RoleServer>,
1144 ) -> Result<CallToolResult, ErrorData> {
1145 let params = params.0;
1146 extract_and_set_trace_context(Some(&context.meta));
1148 let span = tracing::Span::current();
1149 span.record("gen_ai.system", "mcp");
1150 span.record("gen_ai.operation.name", "execute_tool");
1151 span.record("gen_ai.tool.name", "analyze_directory");
1152 span.record("path", ¶ms.path);
1153 let _validated_path = match validate_path(¶ms.path, true) {
1154 Ok(p) => p,
1155 Err(e) => {
1156 span.record("error", true);
1157 span.record("error.type", "invalid_params");
1158 return Ok(err_to_tool_result(e));
1159 }
1160 };
1161 let ct = context.ct.clone();
1162 let t_start = std::time::Instant::now();
1163 let param_path = params.path.clone();
1164 let max_depth_val = params.max_depth;
1165 let seq = self
1166 .session_call_seq
1167 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1168 let sid = self.session_id.lock().await.clone();
1169
1170 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
1172 Ok(v) => v,
1173 Err(e) => {
1174 span.record("error", true);
1175 span.record("error.type", "internal_error");
1176 return Ok(err_to_tool_result(e));
1177 }
1178 };
1179 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1182 Ok(owned) => owned,
1183 Err(arc) => (*arc).clone(),
1184 };
1185
1186 if summary_cursor_conflict(
1189 params.output_control.summary,
1190 params.pagination.cursor.as_deref(),
1191 ) {
1192 span.record("error", true);
1193 span.record("error.type", "invalid_params");
1194 return Ok(err_to_tool_result(ErrorData::new(
1195 rmcp::model::ErrorCode::INVALID_PARAMS,
1196 "summary=true is incompatible with a pagination cursor; use one or the other"
1197 .to_string(),
1198 Some(error_meta(
1199 "validation",
1200 false,
1201 "remove cursor or set summary=false",
1202 )),
1203 )));
1204 }
1205
1206 let use_summary = if params.output_control.force == Some(true) {
1208 false
1209 } else if params.output_control.summary == Some(true) {
1210 true
1211 } else if params.output_control.summary == Some(false) {
1212 false
1213 } else {
1214 output.formatted.len() > SIZE_LIMIT
1215 };
1216
1217 if use_summary {
1218 output.formatted = format_summary(
1219 &output.entries,
1220 &output.files,
1221 params.max_depth,
1222 output.subtree_counts.as_deref(),
1223 );
1224 }
1225
1226 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1228 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1229 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1230 ErrorData::new(
1231 rmcp::model::ErrorCode::INVALID_PARAMS,
1232 e.to_string(),
1233 Some(error_meta("validation", false, "invalid cursor format")),
1234 )
1235 }) {
1236 Ok(v) => v,
1237 Err(e) => {
1238 span.record("error", true);
1239 span.record("error.type", "invalid_params");
1240 return Ok(err_to_tool_result(e));
1241 }
1242 };
1243 cursor_data.offset
1244 } else {
1245 0
1246 };
1247
1248 let paginated =
1250 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1251 Ok(v) => v,
1252 Err(e) => {
1253 span.record("error", true);
1254 span.record("error.type", "internal_error");
1255 return Ok(err_to_tool_result(ErrorData::new(
1256 rmcp::model::ErrorCode::INTERNAL_ERROR,
1257 e.to_string(),
1258 Some(error_meta("transient", true, "retry the request")),
1259 )));
1260 }
1261 };
1262
1263 let verbose = params.output_control.verbose.unwrap_or(false);
1264 if !use_summary {
1265 output.formatted = format_structure_paginated(
1266 &paginated.items,
1267 paginated.total,
1268 params.max_depth,
1269 Some(Path::new(¶ms.path)),
1270 verbose,
1271 );
1272 }
1273
1274 if use_summary {
1276 output.next_cursor = None;
1277 } else {
1278 output.next_cursor.clone_from(&paginated.next_cursor);
1279 }
1280
1281 let mut final_text = output.formatted.clone();
1283 if !use_summary && let Some(cursor) = paginated.next_cursor {
1284 final_text.push('\n');
1285 final_text.push_str("NEXT_CURSOR: ");
1286 final_text.push_str(&cursor);
1287 }
1288
1289 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1290 .with_meta(Some(no_cache_meta()));
1291 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1292 result.structured_content = Some(structured);
1293 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1294 self.metrics_tx.send(crate::metrics::MetricEvent {
1295 ts: crate::metrics::unix_ms(),
1296 tool: "analyze_directory",
1297 duration_ms: dur,
1298 output_chars: final_text.len(),
1299 param_path_depth: crate::metrics::path_component_count(¶m_path),
1300 max_depth: max_depth_val,
1301 result: "ok",
1302 error_type: None,
1303 session_id: sid,
1304 seq: Some(seq),
1305 cache_hit: Some(dir_cache_hit),
1306 });
1307 Ok(result)
1308 }
1309
1310 #[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))]
1311 #[tool(
1312 name = "analyze_file",
1313 title = "Analyze File",
1314 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.",
1315 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1316 annotations(
1317 title = "Analyze File",
1318 read_only_hint = true,
1319 destructive_hint = false,
1320 idempotent_hint = true,
1321 open_world_hint = false
1322 )
1323 )]
1324 async fn analyze_file(
1325 &self,
1326 params: Parameters<AnalyzeFileParams>,
1327 context: RequestContext<RoleServer>,
1328 ) -> Result<CallToolResult, ErrorData> {
1329 let params = params.0;
1330 extract_and_set_trace_context(Some(&context.meta));
1332 let span = tracing::Span::current();
1333 span.record("gen_ai.system", "mcp");
1334 span.record("gen_ai.operation.name", "execute_tool");
1335 span.record("gen_ai.tool.name", "analyze_file");
1336 span.record("path", ¶ms.path);
1337 let _validated_path = match validate_path(¶ms.path, true) {
1338 Ok(p) => p,
1339 Err(e) => {
1340 span.record("error", true);
1341 span.record("error.type", "invalid_params");
1342 return Ok(err_to_tool_result(e));
1343 }
1344 };
1345 let t_start = std::time::Instant::now();
1346 let param_path = params.path.clone();
1347 let seq = self
1348 .session_call_seq
1349 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1350 let sid = self.session_id.lock().await.clone();
1351
1352 if std::path::Path::new(¶ms.path).is_dir() {
1354 span.record("error", true);
1355 span.record("error.type", "invalid_params");
1356 return Ok(err_to_tool_result(ErrorData::new(
1357 rmcp::model::ErrorCode::INVALID_PARAMS,
1358 format!(
1359 "'{}' is a directory; use analyze_directory instead",
1360 params.path
1361 ),
1362 Some(error_meta(
1363 "validation",
1364 false,
1365 "pass a file path, not a directory",
1366 )),
1367 )));
1368 }
1369
1370 if summary_cursor_conflict(
1372 params.output_control.summary,
1373 params.pagination.cursor.as_deref(),
1374 ) {
1375 span.record("error", true);
1376 span.record("error.type", "invalid_params");
1377 return Ok(err_to_tool_result(ErrorData::new(
1378 rmcp::model::ErrorCode::INVALID_PARAMS,
1379 "summary=true is incompatible with a pagination cursor; use one or the other"
1380 .to_string(),
1381 Some(error_meta(
1382 "validation",
1383 false,
1384 "remove cursor or set summary=false",
1385 )),
1386 )));
1387 }
1388
1389 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1391 Ok(v) => v,
1392 Err(e) => {
1393 span.record("error", true);
1394 span.record("error.type", "internal_error");
1395 return Ok(err_to_tool_result(e));
1396 }
1397 };
1398
1399 let mut formatted = arc_output.formatted.clone();
1403 let line_count = arc_output.line_count;
1404
1405 let use_summary = if params.output_control.force == Some(true) {
1407 false
1408 } else if params.output_control.summary == Some(true) {
1409 true
1410 } else if params.output_control.summary == Some(false) {
1411 false
1412 } else {
1413 formatted.len() > SIZE_LIMIT
1414 };
1415
1416 if use_summary {
1417 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1418 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1419 span.record("error", true);
1420 span.record("error.type", "invalid_params");
1421 let estimated_tokens = formatted.len() / 4;
1422 let message = format!(
1423 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1424 - force=true to return full output\n\
1425 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1426 - Use summary=true for a compact overview",
1427 formatted.len(),
1428 estimated_tokens
1429 );
1430 return Ok(err_to_tool_result(ErrorData::new(
1431 rmcp::model::ErrorCode::INVALID_PARAMS,
1432 message,
1433 Some(error_meta(
1434 "validation",
1435 false,
1436 "use force=true, fields, or summary=true",
1437 )),
1438 )));
1439 }
1440
1441 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1443 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1444 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1445 ErrorData::new(
1446 rmcp::model::ErrorCode::INVALID_PARAMS,
1447 e.to_string(),
1448 Some(error_meta("validation", false, "invalid cursor format")),
1449 )
1450 }) {
1451 Ok(v) => v,
1452 Err(e) => {
1453 span.record("error", true);
1454 span.record("error.type", "invalid_params");
1455 return Ok(err_to_tool_result(e));
1456 }
1457 };
1458 cursor_data.offset
1459 } else {
1460 0
1461 };
1462
1463 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1465 .semantic
1466 .functions
1467 .iter()
1468 .filter(|func| {
1469 !arc_output
1470 .semantic
1471 .classes
1472 .iter()
1473 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1474 })
1475 .cloned()
1476 .collect();
1477
1478 let paginated =
1480 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1481 Ok(v) => v,
1482 Err(e) => {
1483 return Ok(err_to_tool_result(ErrorData::new(
1484 rmcp::model::ErrorCode::INTERNAL_ERROR,
1485 e.to_string(),
1486 Some(error_meta("transient", true, "retry the request")),
1487 )));
1488 }
1489 };
1490
1491 let verbose = params.output_control.verbose.unwrap_or(false);
1493 if !use_summary {
1494 formatted = format_file_details_paginated(
1496 &paginated.items,
1497 paginated.total,
1498 &arc_output.semantic,
1499 ¶ms.path,
1500 line_count,
1501 offset,
1502 verbose,
1503 params.fields.as_deref(),
1504 );
1505 }
1506
1507 let next_cursor = if use_summary {
1509 None
1510 } else {
1511 paginated.next_cursor.clone()
1512 };
1513
1514 let mut final_text = formatted.clone();
1516 if !use_summary && let Some(ref cursor) = next_cursor {
1517 final_text.push('\n');
1518 final_text.push_str("NEXT_CURSOR: ");
1519 final_text.push_str(cursor);
1520 }
1521
1522 let response_output = analyze::FileAnalysisOutput::new(
1524 formatted,
1525 arc_output.semantic.clone(),
1526 line_count,
1527 next_cursor,
1528 );
1529
1530 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1531 .with_meta(Some(no_cache_meta()));
1532 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1533 result.structured_content = Some(structured);
1534 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1535 self.metrics_tx.send(crate::metrics::MetricEvent {
1536 ts: crate::metrics::unix_ms(),
1537 tool: "analyze_file",
1538 duration_ms: dur,
1539 output_chars: final_text.len(),
1540 param_path_depth: crate::metrics::path_component_count(¶m_path),
1541 max_depth: None,
1542 result: "ok",
1543 error_type: None,
1544 session_id: sid,
1545 seq: Some(seq),
1546 cache_hit: Some(file_cache_hit),
1547 });
1548 Ok(result)
1549 }
1550
1551 #[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))]
1552 #[tool(
1553 name = "analyze_symbol",
1554 title = "Analyze Symbol",
1555 description = "Call 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.",
1556 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1557 annotations(
1558 title = "Analyze Symbol",
1559 read_only_hint = true,
1560 destructive_hint = false,
1561 idempotent_hint = true,
1562 open_world_hint = false
1563 )
1564 )]
1565 async fn analyze_symbol(
1566 &self,
1567 params: Parameters<AnalyzeSymbolParams>,
1568 context: RequestContext<RoleServer>,
1569 ) -> Result<CallToolResult, ErrorData> {
1570 let params = params.0;
1571 extract_and_set_trace_context(Some(&context.meta));
1573 let span = tracing::Span::current();
1574 span.record("gen_ai.system", "mcp");
1575 span.record("gen_ai.operation.name", "execute_tool");
1576 span.record("gen_ai.tool.name", "analyze_symbol");
1577 span.record("symbol", ¶ms.symbol);
1578 let _validated_path = match validate_path(¶ms.path, true) {
1579 Ok(p) => p,
1580 Err(e) => {
1581 span.record("error", true);
1582 span.record("error.type", "invalid_params");
1583 return Ok(err_to_tool_result(e));
1584 }
1585 };
1586 let ct = context.ct.clone();
1587 let t_start = std::time::Instant::now();
1588 let param_path = params.path.clone();
1589 let max_depth_val = params.follow_depth;
1590 let seq = self
1591 .session_call_seq
1592 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1593 let sid = self.session_id.lock().await.clone();
1594
1595 if std::path::Path::new(¶ms.path).is_file() {
1597 span.record("error", true);
1598 span.record("error.type", "invalid_params");
1599 return Ok(err_to_tool_result(ErrorData::new(
1600 rmcp::model::ErrorCode::INVALID_PARAMS,
1601 format!(
1602 "'{}' is a file; analyze_symbol requires a directory path",
1603 params.path
1604 ),
1605 Some(error_meta(
1606 "validation",
1607 false,
1608 "pass a directory path, not a file",
1609 )),
1610 )));
1611 }
1612
1613 if summary_cursor_conflict(
1615 params.output_control.summary,
1616 params.pagination.cursor.as_deref(),
1617 ) {
1618 span.record("error", true);
1619 span.record("error.type", "invalid_params");
1620 return Ok(err_to_tool_result(ErrorData::new(
1621 rmcp::model::ErrorCode::INVALID_PARAMS,
1622 "summary=true is incompatible with a pagination cursor; use one or the other"
1623 .to_string(),
1624 Some(error_meta(
1625 "validation",
1626 false,
1627 "remove cursor or set summary=false",
1628 )),
1629 )));
1630 }
1631
1632 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1634 span.record("error", true);
1635 span.record("error.type", "invalid_params");
1636 return Ok(err_to_tool_result(e));
1637 }
1638
1639 if params.import_lookup == Some(true) {
1641 let path_owned = PathBuf::from(¶ms.path);
1642 let symbol = params.symbol.clone();
1643 let git_ref = params.git_ref.clone();
1644 let max_depth = params.max_depth;
1645 let ast_recursion_limit = params.ast_recursion_limit;
1646
1647 let handle = tokio::task::spawn_blocking(move || {
1648 let path = path_owned.as_path();
1649 let raw_entries = match walk_directory(path, max_depth) {
1650 Ok(e) => e,
1651 Err(e) => {
1652 return Err(ErrorData::new(
1653 rmcp::model::ErrorCode::INTERNAL_ERROR,
1654 format!("Failed to walk directory: {e}"),
1655 Some(error_meta(
1656 "resource",
1657 false,
1658 "check path permissions and availability",
1659 )),
1660 ));
1661 }
1662 };
1663 let entries = if let Some(ref git_ref_val) = git_ref
1665 && !git_ref_val.is_empty()
1666 {
1667 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1668 Ok(c) => c,
1669 Err(e) => {
1670 return Err(ErrorData::new(
1671 rmcp::model::ErrorCode::INVALID_PARAMS,
1672 format!("git_ref filter failed: {e}"),
1673 Some(error_meta(
1674 "resource",
1675 false,
1676 "ensure git is installed and path is inside a git repository",
1677 )),
1678 ));
1679 }
1680 };
1681 filter_entries_by_git_ref(raw_entries, &changed, path)
1682 } else {
1683 raw_entries
1684 };
1685 let output = match analyze::analyze_import_lookup(
1686 path,
1687 &symbol,
1688 &entries,
1689 ast_recursion_limit,
1690 ) {
1691 Ok(v) => v,
1692 Err(e) => {
1693 return Err(ErrorData::new(
1694 rmcp::model::ErrorCode::INTERNAL_ERROR,
1695 format!("import_lookup failed: {e}"),
1696 Some(error_meta(
1697 "resource",
1698 false,
1699 "check path and file permissions",
1700 )),
1701 ));
1702 }
1703 };
1704 Ok(output)
1705 });
1706
1707 let output = match handle.await {
1708 Ok(Ok(v)) => v,
1709 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1710 Err(e) => {
1711 return Ok(err_to_tool_result(ErrorData::new(
1712 rmcp::model::ErrorCode::INTERNAL_ERROR,
1713 format!("spawn_blocking failed: {e}"),
1714 Some(error_meta("resource", false, "internal error")),
1715 )));
1716 }
1717 };
1718
1719 let final_text = output.formatted.clone();
1720 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1721 .with_meta(Some(no_cache_meta()));
1722 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1723 result.structured_content = Some(structured);
1724 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1725 self.metrics_tx.send(crate::metrics::MetricEvent {
1726 ts: crate::metrics::unix_ms(),
1727 tool: "analyze_symbol",
1728 duration_ms: dur,
1729 output_chars: final_text.len(),
1730 param_path_depth: crate::metrics::path_component_count(¶m_path),
1731 max_depth: max_depth_val,
1732 result: "ok",
1733 error_type: None,
1734 session_id: sid,
1735 seq: Some(seq),
1736 cache_hit: Some(false),
1737 });
1738 return Ok(result);
1739 }
1740
1741 let mut output = match self.handle_focused_mode(¶ms, ct).await {
1743 Ok(v) => v,
1744 Err(e) => return Ok(err_to_tool_result(e)),
1745 };
1746
1747 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1749 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1750 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1751 ErrorData::new(
1752 rmcp::model::ErrorCode::INVALID_PARAMS,
1753 e.to_string(),
1754 Some(error_meta("validation", false, "invalid cursor format")),
1755 )
1756 }) {
1757 Ok(v) => v,
1758 Err(e) => return Ok(err_to_tool_result(e)),
1759 };
1760 cursor_data.offset
1761 } else {
1762 0
1763 };
1764
1765 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
1767 decode_cursor(cursor_str)
1768 .map(|c| c.mode)
1769 .unwrap_or(PaginationMode::Callers)
1770 } else {
1771 PaginationMode::Callers
1772 };
1773
1774 let mut use_summary = params.output_control.summary == Some(true);
1775 if params.output_control.force == Some(true) {
1776 use_summary = false;
1777 }
1778 let verbose = params.output_control.verbose.unwrap_or(false);
1779
1780 let mut callee_cursor = match cursor_mode {
1781 PaginationMode::Callers => {
1782 let (paginated_items, paginated_next) = match paginate_focus_chains(
1783 &output.prod_chains,
1784 PaginationMode::Callers,
1785 offset,
1786 page_size,
1787 ) {
1788 Ok(v) => v,
1789 Err(e) => return Ok(err_to_tool_result(e)),
1790 };
1791
1792 if !use_summary
1793 && (paginated_next.is_some()
1794 || offset > 0
1795 || !verbose
1796 || !output.outgoing_chains.is_empty())
1797 {
1798 let base_path = Path::new(¶ms.path);
1799 output.formatted = format_focused_paginated(
1800 &paginated_items,
1801 output.prod_chains.len(),
1802 PaginationMode::Callers,
1803 ¶ms.symbol,
1804 &output.prod_chains,
1805 &output.test_chains,
1806 &output.outgoing_chains,
1807 output.def_count,
1808 offset,
1809 Some(base_path),
1810 verbose,
1811 );
1812 paginated_next
1813 } else {
1814 None
1815 }
1816 }
1817 PaginationMode::Callees => {
1818 let (paginated_items, paginated_next) = match paginate_focus_chains(
1819 &output.outgoing_chains,
1820 PaginationMode::Callees,
1821 offset,
1822 page_size,
1823 ) {
1824 Ok(v) => v,
1825 Err(e) => return Ok(err_to_tool_result(e)),
1826 };
1827
1828 if paginated_next.is_some() || offset > 0 || !verbose {
1829 let base_path = Path::new(¶ms.path);
1830 output.formatted = format_focused_paginated(
1831 &paginated_items,
1832 output.outgoing_chains.len(),
1833 PaginationMode::Callees,
1834 ¶ms.symbol,
1835 &output.prod_chains,
1836 &output.test_chains,
1837 &output.outgoing_chains,
1838 output.def_count,
1839 offset,
1840 Some(base_path),
1841 verbose,
1842 );
1843 paginated_next
1844 } else {
1845 None
1846 }
1847 }
1848 PaginationMode::Default => {
1849 return Ok(err_to_tool_result(ErrorData::new(
1850 rmcp::model::ErrorCode::INVALID_PARAMS,
1851 "invalid cursor: unknown pagination mode".to_string(),
1852 Some(error_meta(
1853 "validation",
1854 false,
1855 "use a cursor returned by a previous analyze_symbol call",
1856 )),
1857 )));
1858 }
1859 PaginationMode::DefUse => {
1860 let total_sites = output.def_use_sites.len();
1861 let (paginated_sites, paginated_next) = match paginate_slice(
1862 &output.def_use_sites,
1863 offset,
1864 page_size,
1865 PaginationMode::DefUse,
1866 ) {
1867 Ok(r) => (r.items, r.next_cursor),
1868 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
1869 };
1870
1871 if !use_summary {
1874 let base_path = Path::new(¶ms.path);
1875 output.formatted = format_focused_paginated_defuse(
1876 &paginated_sites,
1877 total_sites,
1878 ¶ms.symbol,
1879 offset,
1880 Some(base_path),
1881 verbose,
1882 );
1883 }
1884
1885 output.def_use_sites = paginated_sites;
1888
1889 paginated_next
1890 }
1891 };
1892
1893 if callee_cursor.is_none()
1898 && cursor_mode == PaginationMode::Callers
1899 && !output.outgoing_chains.is_empty()
1900 && !use_summary
1901 && let Ok(cursor) = encode_cursor(&CursorData {
1902 mode: PaginationMode::Callees,
1903 offset: 0,
1904 })
1905 {
1906 callee_cursor = Some(cursor);
1907 }
1908
1909 if callee_cursor.is_none()
1916 && matches!(
1917 cursor_mode,
1918 PaginationMode::Callees | PaginationMode::Callers
1919 )
1920 && !output.def_use_sites.is_empty()
1921 && !use_summary
1922 && let Ok(cursor) = encode_cursor(&CursorData {
1923 mode: PaginationMode::DefUse,
1924 offset: 0,
1925 })
1926 {
1927 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
1930 callee_cursor = Some(cursor);
1931 }
1932 }
1933
1934 output.next_cursor.clone_from(&callee_cursor);
1936
1937 let mut final_text = output.formatted.clone();
1939 if let Some(cursor) = callee_cursor {
1940 final_text.push('\n');
1941 final_text.push_str("NEXT_CURSOR: ");
1942 final_text.push_str(&cursor);
1943 }
1944
1945 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1946 .with_meta(Some(no_cache_meta()));
1947 if cursor_mode != PaginationMode::DefUse {
1951 output.def_use_sites = Vec::new();
1952 }
1953 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1954 result.structured_content = Some(structured);
1955 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1956 self.metrics_tx.send(crate::metrics::MetricEvent {
1957 ts: crate::metrics::unix_ms(),
1958 tool: "analyze_symbol",
1959 duration_ms: dur,
1960 output_chars: final_text.len(),
1961 param_path_depth: crate::metrics::path_component_count(¶m_path),
1962 max_depth: max_depth_val,
1963 result: "ok",
1964 error_type: None,
1965 session_id: sid,
1966 seq: Some(seq),
1967 cache_hit: Some(false),
1968 });
1969 Ok(result)
1970 }
1971
1972 #[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))]
1973 #[tool(
1974 name = "analyze_module",
1975 title = "Analyze Module",
1976 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?",
1977 output_schema = schema_for_type::<types::ModuleInfo>(),
1978 annotations(
1979 title = "Analyze Module",
1980 read_only_hint = true,
1981 destructive_hint = false,
1982 idempotent_hint = true,
1983 open_world_hint = false
1984 )
1985 )]
1986 async fn analyze_module(
1987 &self,
1988 params: Parameters<AnalyzeModuleParams>,
1989 context: RequestContext<RoleServer>,
1990 ) -> Result<CallToolResult, ErrorData> {
1991 let params = params.0;
1992 extract_and_set_trace_context(Some(&context.meta));
1994 let span = tracing::Span::current();
1995 span.record("gen_ai.system", "mcp");
1996 span.record("gen_ai.operation.name", "execute_tool");
1997 span.record("gen_ai.tool.name", "analyze_module");
1998 span.record("path", ¶ms.path);
1999 let _validated_path = match validate_path(¶ms.path, true) {
2000 Ok(p) => p,
2001 Err(e) => {
2002 span.record("error", true);
2003 span.record("error.type", "invalid_params");
2004 return Ok(err_to_tool_result(e));
2005 }
2006 };
2007 let t_start = std::time::Instant::now();
2008 let param_path = params.path.clone();
2009 let seq = self
2010 .session_call_seq
2011 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2012 let sid = self.session_id.lock().await.clone();
2013
2014 if std::fs::metadata(¶ms.path)
2016 .map(|m| m.is_dir())
2017 .unwrap_or(false)
2018 {
2019 span.record("error", true);
2020 span.record("error.type", "invalid_params");
2021 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2022 self.metrics_tx.send(crate::metrics::MetricEvent {
2023 ts: crate::metrics::unix_ms(),
2024 tool: "analyze_module",
2025 duration_ms: dur,
2026 output_chars: 0,
2027 param_path_depth: crate::metrics::path_component_count(¶m_path),
2028 max_depth: None,
2029 result: "error",
2030 error_type: Some("invalid_params".to_string()),
2031 session_id: sid.clone(),
2032 seq: Some(seq),
2033 cache_hit: None,
2034 });
2035 return Ok(err_to_tool_result(ErrorData::new(
2036 rmcp::model::ErrorCode::INVALID_PARAMS,
2037 format!(
2038 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2039 params.path
2040 ),
2041 Some(error_meta(
2042 "validation",
2043 false,
2044 "use analyze_directory for directories",
2045 )),
2046 )));
2047 }
2048
2049 let module_cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
2051 meta.modified().ok().map(|mtime| cache::CacheKey {
2052 path: std::path::PathBuf::from(¶ms.path),
2053 modified: mtime,
2054 mode: AnalysisMode::FileDetails,
2055 })
2056 });
2057 let (module_info, module_cache_hit) = if let Some(ref key) = module_cache_key
2058 && let Some(cached_file) = self.cache.get(key)
2059 {
2060 let file_path = std::path::Path::new(¶ms.path);
2064 let name = file_path
2065 .file_name()
2066 .and_then(|n: &std::ffi::OsStr| n.to_str())
2067 .unwrap_or("unknown")
2068 .to_string();
2069 let language = file_path
2070 .extension()
2071 .and_then(|e| e.to_str())
2072 .and_then(aptu_coder_core::lang::language_for_extension)
2073 .unwrap_or("unknown")
2074 .to_string();
2075 let mut mi = types::ModuleInfo::default();
2076 mi.name = name;
2077 mi.line_count = cached_file.line_count;
2078 mi.language = language;
2079 mi.functions = cached_file
2080 .semantic
2081 .functions
2082 .iter()
2083 .map(|f| {
2084 let mut mfi = types::ModuleFunctionInfo::default();
2085 mfi.name = f.name.clone();
2086 mfi.line = f.line;
2087 mfi
2088 })
2089 .collect();
2090 mi.imports = cached_file
2091 .semantic
2092 .imports
2093 .iter()
2094 .map(|i| {
2095 let mut mii = types::ModuleImportInfo::default();
2096 mii.module = i.module.clone();
2097 mii.items = i.items.clone();
2098 mii
2099 })
2100 .collect();
2101 (mi, true)
2102 } else {
2103 let file_output = match analyze::analyze_file(¶ms.path, None) {
2107 Ok(v) => v,
2108 Err(e) => {
2109 let error_data = match &e {
2110 analyze::AnalyzeError::Io(io_err) => match io_err.kind() {
2111 std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
2112 ErrorData::new(
2113 rmcp::model::ErrorCode::INVALID_PARAMS,
2114 format!("Failed to analyze module: {e}"),
2115 Some(error_meta(
2116 "validation",
2117 false,
2118 "ensure file exists, is readable, and has a supported extension",
2119 )),
2120 )
2121 }
2122 _ => ErrorData::new(
2123 rmcp::model::ErrorCode::INTERNAL_ERROR,
2124 format!("Failed to analyze module: {e}"),
2125 Some(error_meta("internal", false, "report this as a bug")),
2126 ),
2127 },
2128 analyze::AnalyzeError::UnsupportedLanguage(_)
2129 | analyze::AnalyzeError::InvalidRange { .. }
2130 | analyze::AnalyzeError::NotAFile(_) => ErrorData::new(
2131 rmcp::model::ErrorCode::INVALID_PARAMS,
2132 format!("Failed to analyze module: {e}"),
2133 Some(error_meta(
2134 "validation",
2135 false,
2136 "ensure the path is a supported source file",
2137 )),
2138 ),
2139 _ => ErrorData::new(
2140 rmcp::model::ErrorCode::INTERNAL_ERROR,
2141 format!("Failed to analyze module: {e}"),
2142 Some(error_meta("internal", false, "report this as a bug")),
2143 ),
2144 };
2145 return Ok(err_to_tool_result(error_data));
2146 }
2147 };
2148 let arc_output = std::sync::Arc::new(file_output);
2149 if let Some(key) = module_cache_key.clone() {
2150 self.cache.put(key, arc_output.clone());
2151 }
2152 let file_path = std::path::Path::new(¶ms.path);
2153 let name = file_path
2154 .file_name()
2155 .and_then(|n: &std::ffi::OsStr| n.to_str())
2156 .unwrap_or("unknown")
2157 .to_string();
2158 let language = file_path
2159 .extension()
2160 .and_then(|e| e.to_str())
2161 .and_then(aptu_coder_core::lang::language_for_extension)
2162 .unwrap_or("unknown")
2163 .to_string();
2164 let mut mi = types::ModuleInfo::default();
2165 mi.name = name;
2166 mi.line_count = arc_output.line_count;
2167 mi.language = language;
2168 mi.functions = arc_output
2169 .semantic
2170 .functions
2171 .iter()
2172 .map(|f| {
2173 let mut mfi = types::ModuleFunctionInfo::default();
2174 mfi.name = f.name.clone();
2175 mfi.line = f.line;
2176 mfi
2177 })
2178 .collect();
2179 mi.imports = arc_output
2180 .semantic
2181 .imports
2182 .iter()
2183 .map(|i| {
2184 let mut mii = types::ModuleImportInfo::default();
2185 mii.module = i.module.clone();
2186 mii.items = i.items.clone();
2187 mii
2188 })
2189 .collect();
2190 (mi, false)
2191 };
2192
2193 let text = format_module_info(&module_info);
2194 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2195 .with_meta(Some(no_cache_meta()));
2196 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2197 ErrorData::new(
2198 rmcp::model::ErrorCode::INTERNAL_ERROR,
2199 format!("serialization failed: {e}"),
2200 Some(error_meta("internal", false, "report this as a bug")),
2201 )
2202 }) {
2203 Ok(v) => v,
2204 Err(e) => return Ok(err_to_tool_result(e)),
2205 };
2206 result.structured_content = Some(structured);
2207 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2208 self.metrics_tx.send(crate::metrics::MetricEvent {
2209 ts: crate::metrics::unix_ms(),
2210 tool: "analyze_module",
2211 duration_ms: dur,
2212 output_chars: text.len(),
2213 param_path_depth: crate::metrics::path_component_count(¶m_path),
2214 max_depth: None,
2215 result: "ok",
2216 error_type: None,
2217 session_id: sid,
2218 seq: Some(seq),
2219 cache_hit: Some(module_cache_hit),
2220 });
2221 Ok(result)
2222 }
2223
2224 #[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))]
2225 #[tool(
2226 name = "edit_overwrite",
2227 title = "Edit Overwrite",
2228 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.",
2229 output_schema = schema_for_type::<EditOverwriteOutput>(),
2230 annotations(
2231 title = "Edit Overwrite",
2232 read_only_hint = false,
2233 destructive_hint = true,
2234 idempotent_hint = false,
2235 open_world_hint = false
2236 )
2237 )]
2238 async fn edit_overwrite(
2239 &self,
2240 params: Parameters<EditOverwriteParams>,
2241 context: RequestContext<RoleServer>,
2242 ) -> Result<CallToolResult, ErrorData> {
2243 let params = params.0;
2244 extract_and_set_trace_context(Some(&context.meta));
2246 let span = tracing::Span::current();
2247 span.record("gen_ai.system", "mcp");
2248 span.record("gen_ai.operation.name", "execute_tool");
2249 span.record("gen_ai.tool.name", "edit_overwrite");
2250 span.record("path", ¶ms.path);
2251 let _validated_path = if let Some(ref wd) = params.working_dir {
2252 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2253 Ok(p) => p,
2254 Err(e) => {
2255 span.record("error", true);
2256 span.record("error.type", "invalid_params");
2257 return Ok(err_to_tool_result(e));
2258 }
2259 }
2260 } else {
2261 match validate_path(¶ms.path, false) {
2262 Ok(p) => p,
2263 Err(e) => {
2264 span.record("error", true);
2265 span.record("error.type", "invalid_params");
2266 return Ok(err_to_tool_result(e));
2267 }
2268 }
2269 };
2270 let t_start = std::time::Instant::now();
2271 let param_path = params.path.clone();
2272 let seq = self
2273 .session_call_seq
2274 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2275 let sid = self.session_id.lock().await.clone();
2276
2277 if std::fs::metadata(¶ms.path)
2279 .map(|m| m.is_dir())
2280 .unwrap_or(false)
2281 {
2282 span.record("error", true);
2283 span.record("error.type", "invalid_params");
2284 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2285 self.metrics_tx.send(crate::metrics::MetricEvent {
2286 ts: crate::metrics::unix_ms(),
2287 tool: "edit_overwrite",
2288 duration_ms: dur,
2289 output_chars: 0,
2290 param_path_depth: crate::metrics::path_component_count(¶m_path),
2291 max_depth: None,
2292 result: "error",
2293 error_type: Some("invalid_params".to_string()),
2294 session_id: sid.clone(),
2295 seq: Some(seq),
2296 cache_hit: None,
2297 });
2298 return Ok(err_to_tool_result(ErrorData::new(
2299 rmcp::model::ErrorCode::INVALID_PARAMS,
2300 "path is a directory; cannot write to a directory".to_string(),
2301 Some(error_meta(
2302 "validation",
2303 false,
2304 "provide a file path, not a directory",
2305 )),
2306 )));
2307 }
2308
2309 let path = std::path::PathBuf::from(¶ms.path);
2310 let content = params.content.clone();
2311 let handle = tokio::task::spawn_blocking(move || {
2312 aptu_coder_core::edit_overwrite_content(&path, &content)
2313 });
2314
2315 let output = match handle.await {
2316 Ok(Ok(v)) => v,
2317 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2318 span.record("error", true);
2319 span.record("error.type", "invalid_params");
2320 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2321 self.metrics_tx.send(crate::metrics::MetricEvent {
2322 ts: crate::metrics::unix_ms(),
2323 tool: "edit_overwrite",
2324 duration_ms: dur,
2325 output_chars: 0,
2326 param_path_depth: crate::metrics::path_component_count(¶m_path),
2327 max_depth: None,
2328 result: "error",
2329 error_type: Some("invalid_params".to_string()),
2330 session_id: sid.clone(),
2331 seq: Some(seq),
2332 cache_hit: None,
2333 });
2334 return Ok(err_to_tool_result(ErrorData::new(
2335 rmcp::model::ErrorCode::INVALID_PARAMS,
2336 "path is a directory".to_string(),
2337 Some(error_meta(
2338 "validation",
2339 false,
2340 "provide a file path, not a directory",
2341 )),
2342 )));
2343 }
2344 Ok(Err(e)) => {
2345 span.record("error", true);
2346 span.record("error.type", "internal_error");
2347 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2348 self.metrics_tx.send(crate::metrics::MetricEvent {
2349 ts: crate::metrics::unix_ms(),
2350 tool: "edit_overwrite",
2351 duration_ms: dur,
2352 output_chars: 0,
2353 param_path_depth: crate::metrics::path_component_count(¶m_path),
2354 max_depth: None,
2355 result: "error",
2356 error_type: Some("internal_error".to_string()),
2357 session_id: sid.clone(),
2358 seq: Some(seq),
2359 cache_hit: None,
2360 });
2361 return Ok(err_to_tool_result(ErrorData::new(
2362 rmcp::model::ErrorCode::INTERNAL_ERROR,
2363 e.to_string(),
2364 Some(error_meta(
2365 "resource",
2366 false,
2367 "check file path and permissions",
2368 )),
2369 )));
2370 }
2371 Err(e) => {
2372 span.record("error", true);
2373 span.record("error.type", "internal_error");
2374 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2375 self.metrics_tx.send(crate::metrics::MetricEvent {
2376 ts: crate::metrics::unix_ms(),
2377 tool: "edit_overwrite",
2378 duration_ms: dur,
2379 output_chars: 0,
2380 param_path_depth: crate::metrics::path_component_count(¶m_path),
2381 max_depth: None,
2382 result: "error",
2383 error_type: Some("internal_error".to_string()),
2384 session_id: sid.clone(),
2385 seq: Some(seq),
2386 cache_hit: None,
2387 });
2388 return Ok(err_to_tool_result(ErrorData::new(
2389 rmcp::model::ErrorCode::INTERNAL_ERROR,
2390 e.to_string(),
2391 Some(error_meta(
2392 "resource",
2393 false,
2394 "check file path and permissions",
2395 )),
2396 )));
2397 }
2398 };
2399
2400 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2401 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2402 .with_meta(Some(no_cache_meta()));
2403 let structured = match serde_json::to_value(&output).map_err(|e| {
2404 ErrorData::new(
2405 rmcp::model::ErrorCode::INTERNAL_ERROR,
2406 format!("serialization failed: {e}"),
2407 Some(error_meta("internal", false, "report this as a bug")),
2408 )
2409 }) {
2410 Ok(v) => v,
2411 Err(e) => return Ok(err_to_tool_result(e)),
2412 };
2413 result.structured_content = Some(structured);
2414 self.cache
2415 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2416 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2417 self.metrics_tx.send(crate::metrics::MetricEvent {
2418 ts: crate::metrics::unix_ms(),
2419 tool: "edit_overwrite",
2420 duration_ms: dur,
2421 output_chars: text.len(),
2422 param_path_depth: crate::metrics::path_component_count(¶m_path),
2423 max_depth: None,
2424 result: "ok",
2425 error_type: None,
2426 session_id: sid,
2427 seq: Some(seq),
2428 cache_hit: None,
2429 });
2430 Ok(result)
2431 }
2432
2433 #[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))]
2434 #[tool(
2435 name = "edit_replace",
2436 title = "Edit Replace",
2437 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). 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.",
2438 output_schema = schema_for_type::<EditReplaceOutput>(),
2439 annotations(
2440 title = "Edit Replace",
2441 read_only_hint = false,
2442 destructive_hint = true,
2443 idempotent_hint = false,
2444 open_world_hint = false
2445 )
2446 )]
2447 async fn edit_replace(
2448 &self,
2449 params: Parameters<EditReplaceParams>,
2450 context: RequestContext<RoleServer>,
2451 ) -> Result<CallToolResult, ErrorData> {
2452 let params = params.0;
2453 extract_and_set_trace_context(Some(&context.meta));
2455 let span = tracing::Span::current();
2456 span.record("gen_ai.system", "mcp");
2457 span.record("gen_ai.operation.name", "execute_tool");
2458 span.record("gen_ai.tool.name", "edit_replace");
2459 span.record("path", ¶ms.path);
2460 let _validated_path = if let Some(ref wd) = params.working_dir {
2461 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2462 Ok(p) => p,
2463 Err(e) => {
2464 span.record("error", true);
2465 span.record("error.type", "invalid_params");
2466 return Ok(err_to_tool_result(e));
2467 }
2468 }
2469 } else {
2470 match validate_path(¶ms.path, true) {
2471 Ok(p) => p,
2472 Err(e) => {
2473 span.record("error", true);
2474 span.record("error.type", "invalid_params");
2475 return Ok(err_to_tool_result(e));
2476 }
2477 }
2478 };
2479 let t_start = std::time::Instant::now();
2480 let param_path = params.path.clone();
2481 let seq = self
2482 .session_call_seq
2483 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2484 let sid = self.session_id.lock().await.clone();
2485
2486 if std::fs::metadata(¶ms.path)
2488 .map(|m| m.is_dir())
2489 .unwrap_or(false)
2490 {
2491 span.record("error", true);
2492 span.record("error.type", "invalid_params");
2493 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2494 self.metrics_tx.send(crate::metrics::MetricEvent {
2495 ts: crate::metrics::unix_ms(),
2496 tool: "edit_replace",
2497 duration_ms: dur,
2498 output_chars: 0,
2499 param_path_depth: crate::metrics::path_component_count(¶m_path),
2500 max_depth: None,
2501 result: "error",
2502 error_type: Some("invalid_params".to_string()),
2503 session_id: sid.clone(),
2504 seq: Some(seq),
2505 cache_hit: None,
2506 });
2507 return Ok(err_to_tool_result(ErrorData::new(
2508 rmcp::model::ErrorCode::INVALID_PARAMS,
2509 "path is a directory; cannot edit a directory".to_string(),
2510 Some(error_meta(
2511 "validation",
2512 false,
2513 "provide a file path, not a directory",
2514 )),
2515 )));
2516 }
2517
2518 let path = std::path::PathBuf::from(¶ms.path);
2519 let old_text = params.old_text.clone();
2520 let new_text = params.new_text.clone();
2521 let handle = tokio::task::spawn_blocking(move || {
2522 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2523 });
2524
2525 let output = match handle.await {
2526 Ok(Ok(v)) => v,
2527 Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2528 span.record("error", true);
2529 span.record("error.type", "invalid_params");
2530 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2531 self.metrics_tx.send(crate::metrics::MetricEvent {
2532 ts: crate::metrics::unix_ms(),
2533 tool: "edit_replace",
2534 duration_ms: dur,
2535 output_chars: 0,
2536 param_path_depth: crate::metrics::path_component_count(¶m_path),
2537 max_depth: None,
2538 result: "error",
2539 error_type: Some("invalid_params".to_string()),
2540 session_id: sid.clone(),
2541 seq: Some(seq),
2542 cache_hit: None,
2543 });
2544 return Ok(err_to_tool_result(ErrorData::new(
2545 rmcp::model::ErrorCode::INVALID_PARAMS,
2546 "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2547 Some(error_meta(
2548 "validation",
2549 false,
2550 "check that old_text appears in the file",
2551 )),
2552 )));
2553 }
2554 Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
2555 span.record("error", true);
2556 span.record("error.type", "invalid_params");
2557 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2558 self.metrics_tx.send(crate::metrics::MetricEvent {
2559 ts: crate::metrics::unix_ms(),
2560 tool: "edit_replace",
2561 duration_ms: dur,
2562 output_chars: 0,
2563 param_path_depth: crate::metrics::path_component_count(¶m_path),
2564 max_depth: None,
2565 result: "error",
2566 error_type: Some("invalid_params".to_string()),
2567 session_id: sid.clone(),
2568 seq: Some(seq),
2569 cache_hit: None,
2570 });
2571 return Ok(err_to_tool_result(ErrorData::new(
2572 rmcp::model::ErrorCode::INVALID_PARAMS,
2573 format!(
2574 "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2575 ),
2576 Some(error_meta(
2577 "validation",
2578 false,
2579 "include more context in old_text to make it unique",
2580 )),
2581 )));
2582 }
2583 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2584 span.record("error", true);
2585 span.record("error.type", "invalid_params");
2586 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2587 self.metrics_tx.send(crate::metrics::MetricEvent {
2588 ts: crate::metrics::unix_ms(),
2589 tool: "edit_replace",
2590 duration_ms: dur,
2591 output_chars: 0,
2592 param_path_depth: crate::metrics::path_component_count(¶m_path),
2593 max_depth: None,
2594 result: "error",
2595 error_type: Some("invalid_params".to_string()),
2596 session_id: sid.clone(),
2597 seq: Some(seq),
2598 cache_hit: None,
2599 });
2600 return Ok(err_to_tool_result(ErrorData::new(
2601 rmcp::model::ErrorCode::INVALID_PARAMS,
2602 "path is a directory".to_string(),
2603 Some(error_meta(
2604 "validation",
2605 false,
2606 "provide a file path, not a directory",
2607 )),
2608 )));
2609 }
2610 Ok(Err(e)) => {
2611 span.record("error", true);
2612 span.record("error.type", "internal_error");
2613 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2614 self.metrics_tx.send(crate::metrics::MetricEvent {
2615 ts: crate::metrics::unix_ms(),
2616 tool: "edit_replace",
2617 duration_ms: dur,
2618 output_chars: 0,
2619 param_path_depth: crate::metrics::path_component_count(¶m_path),
2620 max_depth: None,
2621 result: "error",
2622 error_type: Some("internal_error".to_string()),
2623 session_id: sid.clone(),
2624 seq: Some(seq),
2625 cache_hit: None,
2626 });
2627 return Ok(err_to_tool_result(ErrorData::new(
2628 rmcp::model::ErrorCode::INTERNAL_ERROR,
2629 e.to_string(),
2630 Some(error_meta(
2631 "resource",
2632 false,
2633 "check file path and permissions",
2634 )),
2635 )));
2636 }
2637 Err(e) => {
2638 span.record("error", true);
2639 span.record("error.type", "internal_error");
2640 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2641 self.metrics_tx.send(crate::metrics::MetricEvent {
2642 ts: crate::metrics::unix_ms(),
2643 tool: "edit_replace",
2644 duration_ms: dur,
2645 output_chars: 0,
2646 param_path_depth: crate::metrics::path_component_count(¶m_path),
2647 max_depth: None,
2648 result: "error",
2649 error_type: Some("internal_error".to_string()),
2650 session_id: sid.clone(),
2651 seq: Some(seq),
2652 cache_hit: None,
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 };
2665
2666 let text = format!(
2667 "Edited {}: {} bytes -> {} bytes",
2668 output.path, output.bytes_before, output.bytes_after
2669 );
2670 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2671 .with_meta(Some(no_cache_meta()));
2672 let structured = match serde_json::to_value(&output).map_err(|e| {
2673 ErrorData::new(
2674 rmcp::model::ErrorCode::INTERNAL_ERROR,
2675 format!("serialization failed: {e}"),
2676 Some(error_meta("internal", false, "report this as a bug")),
2677 )
2678 }) {
2679 Ok(v) => v,
2680 Err(e) => return Ok(err_to_tool_result(e)),
2681 };
2682 result.structured_content = Some(structured);
2683 self.cache
2684 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2685 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2686 self.metrics_tx.send(crate::metrics::MetricEvent {
2687 ts: crate::metrics::unix_ms(),
2688 tool: "edit_replace",
2689 duration_ms: dur,
2690 output_chars: text.len(),
2691 param_path_depth: crate::metrics::path_component_count(¶m_path),
2692 max_depth: None,
2693 result: "ok",
2694 error_type: None,
2695 session_id: sid,
2696 seq: Some(seq),
2697 cache_hit: None,
2698 });
2699 Ok(result)
2700 }
2701
2702 #[tool(
2703 name = "exec_command",
2704 title = "Exec Command",
2705 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.",
2706 output_schema = schema_for_type::<types::ShellOutput>(),
2707 annotations(
2708 title = "Exec Command",
2709 read_only_hint = false,
2710 destructive_hint = true,
2711 idempotent_hint = false,
2712 open_world_hint = true
2713 )
2714 )]
2715 #[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))]
2716 pub async fn exec_command(
2717 &self,
2718 params: Parameters<types::ExecCommandParams>,
2719 context: RequestContext<RoleServer>,
2720 ) -> Result<CallToolResult, ErrorData> {
2721 let t_start = std::time::Instant::now();
2722 let params = params.0;
2723 extract_and_set_trace_context(Some(&context.meta));
2725 let span = tracing::Span::current();
2726 span.record("gen_ai.system", "mcp");
2727 span.record("gen_ai.operation.name", "execute_tool");
2728 span.record("gen_ai.tool.name", "exec_command");
2729 span.record("command", ¶ms.command);
2730
2731 let working_dir_path = if let Some(ref wd) = params.working_dir {
2733 match validate_path(wd, true) {
2734 Ok(p) => {
2735 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
2737 span.record("error", true);
2738 span.record("error.type", "invalid_params");
2739 return Ok(err_to_tool_result(ErrorData::new(
2740 rmcp::model::ErrorCode::INVALID_PARAMS,
2741 "working_dir must be a directory".to_string(),
2742 Some(error_meta(
2743 "validation",
2744 false,
2745 "provide a valid directory path",
2746 )),
2747 )));
2748 }
2749 Some(p)
2750 }
2751 Err(e) => {
2752 span.record("error", true);
2753 span.record("error.type", "invalid_params");
2754 return Ok(err_to_tool_result(e));
2755 }
2756 }
2757 } else {
2758 None
2759 };
2760
2761 let param_path = params.working_dir.clone();
2762 let seq = self
2763 .session_call_seq
2764 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2765 let sid = self.session_id.lock().await.clone();
2766
2767 if let Some(ref stdin_content) = params.stdin
2769 && stdin_content.len() > STDIN_MAX_BYTES
2770 {
2771 span.record("error", true);
2772 span.record("error.type", "invalid_params");
2773 return Ok(err_to_tool_result(ErrorData::new(
2774 rmcp::model::ErrorCode::INVALID_PARAMS,
2775 "stdin exceeds 1 MB limit".to_string(),
2776 Some(error_meta("validation", false, "reduce stdin content size")),
2777 )));
2778 }
2779
2780 let command = params.command.clone();
2781 let timeout_secs = params.timeout_secs;
2782
2783 let cache_key = (
2785 command.clone(),
2786 working_dir_path
2787 .as_ref()
2788 .map(|p| p.display().to_string())
2789 .unwrap_or_default(),
2790 );
2791 let use_cache = params.cache.unwrap_or(true) && params.stdin.is_none();
2792
2793 let was_cached = if use_cache {
2795 self.exec_cache.contains_key(&cache_key)
2796 } else {
2797 false
2798 };
2799
2800 let output = if use_cache {
2802 self.exec_cache
2803 .get_with(cache_key.clone(), async {
2804 run_exec_impl(
2805 command.clone(),
2806 working_dir_path.clone(),
2807 timeout_secs,
2808 params.memory_limit_mb,
2809 params.cpu_limit_secs,
2810 params.stdin.clone(),
2811 seq,
2812 )
2813 .await
2814 })
2815 .await
2816 } else {
2817 run_exec_impl(
2818 command.clone(),
2819 working_dir_path.clone(),
2820 timeout_secs,
2821 params.memory_limit_mb,
2822 params.cpu_limit_secs,
2823 params.stdin.clone(),
2824 seq,
2825 )
2826 .await
2827 };
2828
2829 if use_cache && output.exit_code.map(|c| c != 0).unwrap_or(false) {
2831 self.exec_cache.invalidate(&cache_key).await;
2832 }
2833
2834 let exit_code = output.exit_code;
2835 let timed_out = output.timed_out;
2836 let output_truncated = output.output_truncated;
2837
2838 if let Some(code) = exit_code {
2840 span.record("exit_code", code);
2841 }
2842 span.record("timed_out", timed_out);
2843 span.record("output_truncated", output_truncated);
2844
2845 if output_truncated {
2847 tracing::debug!(truncated = true, message = "output truncated");
2848 }
2849
2850 let output_text = if output.interleaved.is_empty() {
2852 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
2853 } else {
2854 format!("Output:\n{}", output.interleaved)
2855 };
2856
2857 let text = format!(
2858 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
2859 params.command,
2860 exit_code
2861 .map(|c| c.to_string())
2862 .unwrap_or_else(|| "null".to_string()),
2863 timed_out,
2864 output_truncated,
2865 output_text,
2866 );
2867
2868 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
2869
2870 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
2875
2876 let mut result = if command_failed {
2877 CallToolResult::error(content_blocks)
2878 } else {
2879 CallToolResult::success(content_blocks)
2880 }
2881 .with_meta(Some(no_cache_meta()));
2882
2883 let structured = match serde_json::to_value(&output).map_err(|e| {
2884 ErrorData::new(
2885 rmcp::model::ErrorCode::INTERNAL_ERROR,
2886 format!("serialization failed: {e}"),
2887 Some(error_meta("internal", false, "report this as a bug")),
2888 )
2889 }) {
2890 Ok(v) => v,
2891 Err(e) => {
2892 span.record("error", true);
2893 span.record("error.type", "internal_error");
2894 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2895 self.metrics_tx.send(crate::metrics::MetricEvent {
2896 ts: crate::metrics::unix_ms(),
2897 tool: "exec_command",
2898 duration_ms: dur,
2899 output_chars: 0,
2900 param_path_depth: crate::metrics::path_component_count(
2901 param_path.as_deref().unwrap_or(""),
2902 ),
2903 max_depth: None,
2904 result: "error",
2905 error_type: Some("internal_error".to_string()),
2906 session_id: sid.clone(),
2907 seq: Some(seq),
2908 cache_hit: Some(was_cached),
2909 });
2910 return Ok(err_to_tool_result(e));
2911 }
2912 };
2913
2914 result.structured_content = Some(structured);
2915 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2916 self.metrics_tx.send(crate::metrics::MetricEvent {
2917 ts: crate::metrics::unix_ms(),
2918 tool: "exec_command",
2919 duration_ms: dur,
2920 output_chars: text.len(),
2921 param_path_depth: crate::metrics::path_component_count(
2922 param_path.as_deref().unwrap_or(""),
2923 ),
2924 max_depth: None,
2925 result: "ok",
2926 error_type: None,
2927 session_id: sid,
2928 seq: Some(seq),
2929 cache_hit: Some(was_cached),
2930 });
2931 Ok(result)
2932 }
2933}
2934
2935async fn run_exec_impl(
2939 command: String,
2940 working_dir_path: Option<std::path::PathBuf>,
2941 timeout_secs: Option<u64>,
2942 memory_limit_mb: Option<u64>,
2943 cpu_limit_secs: Option<u64>,
2944 stdin: Option<String>,
2945 seq: u32,
2946) -> types::ShellOutput {
2947 use tokio::io::AsyncBufReadExt as _;
2948 use tokio_stream::StreamExt as TokioStreamExt;
2949 use tokio_stream::wrappers::LinesStream;
2950
2951 let shell = resolve_shell();
2952 let mut cmd = tokio::process::Command::new(shell);
2953 cmd.arg("-c").arg(&command);
2954
2955 if let Some(ref wd) = working_dir_path {
2956 cmd.current_dir(wd);
2957 }
2958
2959 cmd.stdout(std::process::Stdio::piped())
2960 .stderr(std::process::Stdio::piped());
2961
2962 if stdin.is_some() {
2963 cmd.stdin(std::process::Stdio::piped());
2964 } else {
2965 cmd.stdin(std::process::Stdio::null());
2966 }
2967
2968 #[cfg(unix)]
2969 {
2970 #[cfg(not(target_os = "linux"))]
2971 if memory_limit_mb.is_some() {
2972 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
2973 }
2974 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
2975 unsafe {
2976 cmd.pre_exec(move || {
2977 #[cfg(target_os = "linux")]
2978 if let Some(mb) = memory_limit_mb {
2979 let bytes = mb.saturating_mul(1024 * 1024);
2980 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
2981 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
2982 }
2983 if let Some(cpu) = cpu_limit_secs {
2984 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
2985 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
2986 }
2987 Ok(())
2988 });
2989 }
2990 }
2991 }
2992
2993 let mut child = match cmd.spawn() {
2994 Ok(c) => c,
2995 Err(e) => {
2996 return types::ShellOutput::new(
2997 String::new(),
2998 format!("failed to spawn command: {e}"),
2999 format!("failed to spawn command: {e}"),
3000 None,
3001 false,
3002 false,
3003 );
3004 }
3005 };
3006
3007 let stdout_pipe = child.stdout.take();
3008 let stderr_pipe = child.stderr.take();
3009
3010 if let Some(stdin_content) = stdin
3011 && let Some(mut stdin_handle) = child.stdin.take()
3012 {
3013 use tokio::io::AsyncWriteExt as _;
3014 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3015 Ok(()) => {
3016 drop(stdin_handle);
3017 }
3018 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3019 Err(e) => {
3020 warn!("failed to write stdin: {e}");
3021 }
3022 }
3023 }
3024
3025 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3026
3027 let mut drain_task = tokio::spawn(async move {
3028 let so_stream = stdout_pipe.map(|p| {
3029 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3030 });
3031 let se_stream = stderr_pipe.map(|p| {
3032 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3033 });
3034
3035 match (so_stream, se_stream) {
3036 (Some(so), Some(se)) => {
3037 let mut merged = so.merge(se);
3038 while let Some(Ok((is_stderr, line))) = merged.next().await {
3039 let _ = tx.send((is_stderr, line));
3040 }
3041 }
3042 (Some(so), None) => {
3043 let mut stream = so;
3044 while let Some(Ok((_, line))) = stream.next().await {
3045 let _ = tx.send((false, line));
3046 }
3047 }
3048 (None, Some(se)) => {
3049 let mut stream = se;
3050 while let Some(Ok((_, line))) = stream.next().await {
3051 let _ = tx.send((true, line));
3052 }
3053 }
3054 (None, None) => {}
3055 }
3056 });
3057
3058 let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
3059 _ = &mut drain_task => {
3060 let (status, drain_truncated) = match tokio::time::timeout(
3061 std::time::Duration::from_millis(500),
3062 child.wait()
3063 ).await {
3064 Ok(Ok(s)) => (Some(s), false),
3065 Ok(Err(_)) => (None, false),
3066 Err(_) => {
3067 child.start_kill().ok();
3068 let _ = child.wait().await;
3069 (None, true)
3070 }
3071 };
3072 let exit_code = status.and_then(|s| s.code());
3073 let ocerr = if drain_truncated {
3074 Some("post-exit drain timeout: background process held pipes".to_string())
3075 } else {
3076 None
3077 };
3078 (exit_code, false, drain_truncated, ocerr)
3079 }
3080 _ = async {
3081 if let Some(secs) = timeout_secs {
3082 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3083 } else {
3084 std::future::pending::<()>().await;
3085 }
3086 } => {
3087 let _ = child.kill().await;
3088 let _ = child.wait().await;
3089 drain_task.abort();
3090 (None, true, false, None)
3091 }
3092 };
3093
3094 let mut lines: Vec<(bool, String)> = Vec::new();
3095 while let Some(item) = rx.recv().await {
3096 lines.push(item);
3097 }
3098
3099 const MAX_BYTES: usize = 50 * 1024;
3101 let mut stdout_str = String::new();
3102 let mut stderr_str = String::new();
3103 let mut interleaved_str = String::new();
3104 let mut so_bytes = 0usize;
3105 let mut se_bytes = 0usize;
3106 let mut il_bytes = 0usize;
3107 for (is_stderr, line) in &lines {
3108 let entry = format!("{line}\n");
3109 if il_bytes < 2 * MAX_BYTES {
3110 il_bytes += entry.len();
3111 interleaved_str.push_str(&entry);
3112 }
3113 if *is_stderr {
3114 if se_bytes < MAX_BYTES {
3115 se_bytes += entry.len();
3116 stderr_str.push_str(&entry);
3117 }
3118 } else if so_bytes < MAX_BYTES {
3119 so_bytes += entry.len();
3120 stdout_str.push_str(&entry);
3121 }
3122 }
3123
3124 let slot = seq % 8;
3125 let (stdout, stderr, stdout_path, stderr_path) =
3126 handle_output_persist(stdout_str, stderr_str, slot);
3127 output_truncated = output_truncated || stdout_path.is_some();
3128
3129 let mut output = types::ShellOutput::new(
3130 stdout,
3131 stderr,
3132 interleaved_str,
3133 exit_code,
3134 timed_out,
3135 output_truncated,
3136 );
3137 output.output_collection_error = output_collection_error;
3138 output.stdout_path = stdout_path;
3139 output.stderr_path = stderr_path;
3140
3141 output
3142}
3143
3144fn handle_output_persist(
3151 stdout: String,
3152 stderr: String,
3153 slot: u32,
3154) -> (String, String, Option<String>, Option<String>) {
3155 const MAX_OUTPUT_LINES: usize = 2000;
3156 const OVERFLOW_PREVIEW_LINES: usize = 50;
3157
3158 let stdout_lines: Vec<&str> = stdout.lines().collect();
3159 let stderr_lines: Vec<&str> = stderr.lines().collect();
3160
3161 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3163 return (stdout, stderr, None, None);
3164 }
3165
3166 let base = std::env::temp_dir()
3168 .join("aptu-coder-overflow")
3169 .join(format!("slot-{slot}"));
3170 let _ = std::fs::create_dir_all(&base);
3171
3172 let stdout_path = base.join("stdout");
3173 let stderr_path = base.join("stderr");
3174
3175 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3176 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3177
3178 let stdout_path_str = stdout_path.display().to_string();
3179 let stderr_path_str = stderr_path.display().to_string();
3180
3181 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3182 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3183 } else {
3184 stdout
3185 };
3186 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3187 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3188 } else {
3189 stderr
3190 };
3191
3192 (
3193 stdout_preview,
3194 stderr_preview,
3195 Some(stdout_path_str),
3196 Some(stderr_path_str),
3197 )
3198}
3199
3200#[derive(Clone)]
3204struct FocusedAnalysisParams {
3205 path: std::path::PathBuf,
3206 symbol: String,
3207 match_mode: SymbolMatchMode,
3208 follow_depth: u32,
3209 max_depth: Option<u32>,
3210 ast_recursion_limit: Option<usize>,
3211 use_summary: bool,
3212 impl_only: Option<bool>,
3213 def_use: bool,
3214 parse_timeout_micros: Option<u64>,
3215}
3216
3217#[tool_handler]
3218impl ServerHandler for CodeAnalyzer {
3219 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3220 async fn initialize(
3221 &self,
3222 _request: InitializeRequestParams,
3223 context: RequestContext<RoleServer>,
3224 ) -> Result<InitializeResult, ErrorData> {
3225 let span = tracing::Span::current();
3226 span.record("service.name", "aptu-coder");
3227 span.record("service.version", env!("CARGO_PKG_VERSION"));
3228
3229 if let Some(meta) = context.extensions.get::<Meta>() {
3232 let mut meta_lock = self.profile_meta.lock().await;
3233 *meta_lock = Some(meta.0.clone());
3234 }
3235 Ok(self.get_info())
3236 }
3237
3238 fn get_info(&self) -> InitializeResult {
3239 let excluded = crate::EXCLUDED_DIRS.join(", ");
3240 let instructions = format!(
3241 "Recommended workflow:\n\
3242 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3243 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\
3244 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3245 4. Use analyze_symbol to trace call graphs.\n\
3246 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."
3247 );
3248 let capabilities = ServerCapabilities::builder()
3249 .enable_logging()
3250 .enable_tools()
3251 .enable_tool_list_changed()
3252 .enable_completions()
3253 .build();
3254 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3255 .with_title("Aptu Coder")
3256 .with_description("MCP server for code structure analysis using tree-sitter");
3257 InitializeResult::new(capabilities)
3258 .with_server_info(server_info)
3259 .with_instructions(&instructions)
3260 }
3261
3262 async fn list_tools(
3263 &self,
3264 _request: Option<rmcp::model::PaginatedRequestParams>,
3265 _context: RequestContext<RoleServer>,
3266 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3267 let router = self.tool_router.read().await;
3268 Ok(rmcp::model::ListToolsResult {
3269 tools: router.list_all(),
3270 meta: None,
3271 next_cursor: None,
3272 })
3273 }
3274
3275 async fn call_tool(
3276 &self,
3277 request: rmcp::model::CallToolRequestParams,
3278 context: RequestContext<RoleServer>,
3279 ) -> Result<CallToolResult, ErrorData> {
3280 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3281 let router = self.tool_router.read().await;
3282 router.call(tcc).await
3283 }
3284
3285 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3286 let mut peer_lock = self.peer.lock().await;
3287 *peer_lock = Some(context.peer.clone());
3288 drop(peer_lock);
3289
3290 let millis = std::time::SystemTime::now()
3292 .duration_since(std::time::UNIX_EPOCH)
3293 .unwrap_or_default()
3294 .as_millis()
3295 .try_into()
3296 .unwrap_or(u64::MAX);
3297 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3298 let sid = format!("{millis}-{counter}");
3299 {
3300 let mut session_id_lock = self.session_id.lock().await;
3301 *session_id_lock = Some(sid);
3302 }
3303 self.session_call_seq
3304 .store(0, std::sync::atomic::Ordering::Relaxed);
3305
3306 let meta_lock = self.profile_meta.lock().await;
3316 let meta_profile = meta_lock
3317 .as_ref()
3318 .and_then(|m| m.get("io.clouatre-labs/profile"))
3319 .and_then(|v| v.as_str())
3320 .map(str::to_owned);
3321 drop(meta_lock);
3322
3323 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3325
3326 if let Some(ref profile) = active_profile {
3327 let mut router = self.tool_router.write().await;
3328 match profile.as_str() {
3329 "edit" => {
3330 router.disable_route("analyze_directory");
3332 router.disable_route("analyze_file");
3333 router.disable_route("analyze_module");
3334 router.disable_route("analyze_symbol");
3335 }
3336 "analyze" => {
3337 router.disable_route("edit_replace");
3339 router.disable_route("edit_overwrite");
3340 }
3341 _ => {
3342 }
3344 }
3345 router.bind_peer_notifier(&context.peer);
3347 }
3348
3349 let peer = self.peer.clone();
3351 let event_rx = self.event_rx.clone();
3352
3353 tokio::spawn(async move {
3354 let rx = {
3355 let mut rx_lock = event_rx.lock().await;
3356 rx_lock.take()
3357 };
3358
3359 if let Some(mut receiver) = rx {
3360 let mut buffer = Vec::with_capacity(64);
3361 loop {
3362 receiver.recv_many(&mut buffer, 64).await;
3364
3365 if buffer.is_empty() {
3366 break;
3368 }
3369
3370 let peer_lock = peer.lock().await;
3372 if let Some(peer) = peer_lock.as_ref() {
3373 for log_event in buffer.drain(..) {
3374 let notification = ServerNotification::LoggingMessageNotification(
3375 Notification::new(LoggingMessageNotificationParam {
3376 level: log_event.level,
3377 logger: Some(log_event.logger),
3378 data: log_event.data,
3379 }),
3380 );
3381 if let Err(e) = peer.send_notification(notification).await {
3382 warn!("Failed to send logging notification: {}", e);
3383 }
3384 }
3385 }
3386 }
3387 }
3388 });
3389 }
3390
3391 #[instrument(skip(self, _context))]
3392 async fn on_cancelled(
3393 &self,
3394 notification: CancelledNotificationParam,
3395 _context: NotificationContext<RoleServer>,
3396 ) {
3397 tracing::info!(
3398 request_id = ?notification.request_id,
3399 reason = ?notification.reason,
3400 "Received cancellation notification"
3401 );
3402 }
3403
3404 #[instrument(skip(self, _context))]
3405 async fn complete(
3406 &self,
3407 request: CompleteRequestParams,
3408 _context: RequestContext<RoleServer>,
3409 ) -> Result<CompleteResult, ErrorData> {
3410 let argument_name = &request.argument.name;
3412 let argument_value = &request.argument.value;
3413
3414 let completions = match argument_name.as_str() {
3415 "path" => {
3416 let root = Path::new(".");
3418 completion::path_completions(root, argument_value)
3419 }
3420 "symbol" => {
3421 let path_arg = request
3423 .context
3424 .as_ref()
3425 .and_then(|ctx| ctx.get_argument("path"));
3426
3427 match path_arg {
3428 Some(path_str) => {
3429 let path = Path::new(path_str);
3430 completion::symbol_completions(&self.cache, path, argument_value)
3431 }
3432 None => Vec::new(),
3433 }
3434 }
3435 _ => Vec::new(),
3436 };
3437
3438 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3440 let (values, has_more) = if completions.len() > 100 {
3441 (completions.into_iter().take(100).collect(), true)
3442 } else {
3443 (completions, false)
3444 };
3445
3446 let completion_info =
3447 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3448 Ok(info) => info,
3449 Err(_) => {
3450 CompletionInfo::with_all_values(Vec::new())
3452 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3453 }
3454 };
3455
3456 Ok(CompleteResult::new(completion_info))
3457 }
3458
3459 async fn set_level(
3460 &self,
3461 params: SetLevelRequestParams,
3462 _context: RequestContext<RoleServer>,
3463 ) -> Result<(), ErrorData> {
3464 let level_filter = match params.level {
3465 LoggingLevel::Debug => LevelFilter::DEBUG,
3466 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3467 LoggingLevel::Warning => LevelFilter::WARN,
3468 LoggingLevel::Error
3469 | LoggingLevel::Critical
3470 | LoggingLevel::Alert
3471 | LoggingLevel::Emergency => LevelFilter::ERROR,
3472 };
3473
3474 let mut filter_lock = self
3475 .log_level_filter
3476 .lock()
3477 .unwrap_or_else(|e| e.into_inner());
3478 *filter_lock = level_filter;
3479 Ok(())
3480 }
3481}
3482
3483#[cfg(test)]
3484mod tests {
3485 use super::*;
3486
3487 #[tokio::test]
3488 async fn test_emit_progress_none_peer_is_noop() {
3489 let peer = Arc::new(TokioMutex::new(None));
3490 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3491 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3492 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3493 let analyzer = CodeAnalyzer::new(
3494 peer,
3495 log_level_filter,
3496 rx,
3497 crate::metrics::MetricsSender(metrics_tx),
3498 );
3499 let token = ProgressToken(NumberOrString::String("test".into()));
3500 analyzer
3502 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3503 .await;
3504 }
3505
3506 fn make_analyzer() -> CodeAnalyzer {
3507 let peer = Arc::new(TokioMutex::new(None));
3508 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3509 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3510 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3511 CodeAnalyzer::new(
3512 peer,
3513 log_level_filter,
3514 rx,
3515 crate::metrics::MetricsSender(metrics_tx),
3516 )
3517 }
3518
3519 #[test]
3520 fn test_summary_cursor_conflict() {
3521 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3522 assert!(!summary_cursor_conflict(Some(true), None));
3523 assert!(!summary_cursor_conflict(None, Some("x")));
3524 assert!(!summary_cursor_conflict(None, None));
3525 }
3526
3527 #[tokio::test]
3528 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3529 use tempfile::TempDir;
3530
3531 let dir = TempDir::new().unwrap();
3532 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3533
3534 let analyzer = make_analyzer();
3535 let entries: Vec<traversal::WalkEntry> =
3538 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3539 let result = CodeAnalyzer::validate_impl_only(&entries);
3540 assert!(result.is_err());
3541 let err = result.unwrap_err();
3542 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3543 drop(analyzer); }
3545
3546 #[tokio::test]
3547 async fn test_no_cache_meta_on_analyze_directory_result() {
3548 use aptu_coder_core::types::{
3549 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3550 };
3551 use tempfile::TempDir;
3552
3553 let dir = TempDir::new().unwrap();
3554 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3555
3556 let analyzer = make_analyzer();
3557 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3558 "path": dir.path().to_str().unwrap(),
3559 }))
3560 .unwrap();
3561 let ct = tokio_util::sync::CancellationToken::new();
3562 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3563 let meta = no_cache_meta();
3565 assert_eq!(
3566 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3567 Some("no-cache"),
3568 );
3569 drop(arc_output);
3570 }
3571
3572 #[test]
3573 fn test_complete_path_completions_returns_suggestions() {
3574 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3579 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3580 let suggestions = completion::path_completions(workspace_root, "aptu-");
3581 assert!(
3582 !suggestions.is_empty(),
3583 "expected completions for prefix 'aptu-' in workspace root"
3584 );
3585 }
3586
3587 #[tokio::test]
3588 async fn test_handle_overview_mode_verbose_no_summary_block() {
3589 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3590 use aptu_coder_core::types::{
3591 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3592 };
3593 use tempfile::TempDir;
3594
3595 let tmp = TempDir::new().unwrap();
3596 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3597
3598 let peer = Arc::new(TokioMutex::new(None));
3599 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3600 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3601 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3602 let analyzer = CodeAnalyzer::new(
3603 peer,
3604 log_level_filter,
3605 rx,
3606 crate::metrics::MetricsSender(metrics_tx),
3607 );
3608
3609 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3610 "path": tmp.path().to_str().unwrap(),
3611 "verbose": true,
3612 }))
3613 .unwrap();
3614
3615 let ct = tokio_util::sync::CancellationToken::new();
3616 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3617
3618 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
3621 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3622 let verbose = true;
3623 let formatted = if !use_summary {
3624 format_structure_paginated(
3625 &paginated.items,
3626 paginated.total,
3627 params.max_depth,
3628 Some(std::path::Path::new(¶ms.path)),
3629 verbose,
3630 )
3631 } else {
3632 output.formatted.clone()
3633 };
3634
3635 assert!(
3637 !formatted.contains("SUMMARY:"),
3638 "verbose=true must not emit SUMMARY: block; got: {}",
3639 &formatted[..formatted.len().min(300)]
3640 );
3641 assert!(
3642 formatted.contains("PAGINATED:"),
3643 "verbose=true must emit PAGINATED: header"
3644 );
3645 assert!(
3646 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
3647 "verbose=true must emit FILES section header"
3648 );
3649 }
3650
3651 #[tokio::test]
3654 async fn test_analyze_directory_cache_hit_metrics() {
3655 use aptu_coder_core::types::{
3656 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3657 };
3658 use tempfile::TempDir;
3659
3660 let dir = TempDir::new().unwrap();
3662 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
3663 let analyzer = make_analyzer();
3664 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3665 "path": dir.path().to_str().unwrap(),
3666 }))
3667 .unwrap();
3668
3669 let ct1 = tokio_util::sync::CancellationToken::new();
3671 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
3672
3673 let ct2 = tokio_util::sync::CancellationToken::new();
3675 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
3676
3677 assert!(!hit1, "first call must be a cache miss");
3679 assert!(hit2, "second call must be a cache hit");
3680 }
3681
3682 #[tokio::test]
3683 async fn test_analyze_module_cache_hit_metrics() {
3684 use std::io::Write as _;
3685 use tempfile::NamedTempFile;
3686
3687 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
3689 writeln!(f, "fn bar() {{}}").unwrap();
3690 let path = f.path().to_str().unwrap().to_string();
3691
3692 let analyzer = make_analyzer();
3693
3694 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
3696 file_params.path = path.clone();
3697 file_params.ast_recursion_limit = None;
3698 file_params.fields = None;
3699 file_params.pagination.cursor = None;
3700 file_params.pagination.page_size = None;
3701 file_params.output_control.summary = None;
3702 file_params.output_control.force = None;
3703 file_params.output_control.verbose = None;
3704 let (_cached, _) = analyzer
3705 .handle_file_details_mode(&file_params)
3706 .await
3707 .unwrap();
3708
3709 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
3711 module_params.path = path.clone();
3712
3713 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
3715 meta.modified()
3716 .ok()
3717 .map(|mtime| aptu_coder_core::cache::CacheKey {
3718 path: std::path::PathBuf::from(&path),
3719 modified: mtime,
3720 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
3721 })
3722 });
3723 let cache_hit = module_cache_key
3724 .as_ref()
3725 .and_then(|k| analyzer.cache.get(k))
3726 .is_some();
3727
3728 assert!(
3730 cache_hit,
3731 "analyze_module should find the file in the shared file cache"
3732 );
3733 drop(module_params);
3734 }
3735
3736 #[test]
3739 fn test_analyze_symbol_import_lookup_invalid_params() {
3740 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
3744
3745 assert!(
3747 result.is_err(),
3748 "import_lookup=true with empty symbol must return Err"
3749 );
3750 let err = result.unwrap_err();
3751 assert_eq!(
3752 err.code,
3753 rmcp::model::ErrorCode::INVALID_PARAMS,
3754 "expected INVALID_PARAMS; got {:?}",
3755 err.code
3756 );
3757 }
3758
3759 #[tokio::test]
3760 async fn test_analyze_symbol_import_lookup_found() {
3761 use tempfile::TempDir;
3762
3763 let dir = TempDir::new().unwrap();
3765 std::fs::write(
3766 dir.path().join("main.rs"),
3767 "use std::collections::HashMap;\nfn main() {}\n",
3768 )
3769 .unwrap();
3770
3771 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3772
3773 let output =
3775 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
3776
3777 assert!(
3779 output.formatted.contains("MATCHES: 1"),
3780 "expected 1 match; got: {}",
3781 output.formatted
3782 );
3783 assert!(
3784 output.formatted.contains("main.rs"),
3785 "expected main.rs in output; got: {}",
3786 output.formatted
3787 );
3788 }
3789
3790 #[tokio::test]
3791 async fn test_analyze_symbol_import_lookup_empty() {
3792 use tempfile::TempDir;
3793
3794 let dir = TempDir::new().unwrap();
3796 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
3797
3798 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3799
3800 let output =
3802 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
3803
3804 assert!(
3806 output.formatted.contains("MATCHES: 0"),
3807 "expected 0 matches; got: {}",
3808 output.formatted
3809 );
3810 }
3811
3812 #[tokio::test]
3815 async fn test_analyze_directory_git_ref_non_git_repo() {
3816 use aptu_coder_core::traversal::changed_files_from_git_ref;
3817 use tempfile::TempDir;
3818
3819 let dir = TempDir::new().unwrap();
3821 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3822
3823 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
3825
3826 assert!(result.is_err(), "non-git dir must return an error");
3828 let err_msg = result.unwrap_err().to_string();
3829 assert!(
3830 err_msg.contains("git"),
3831 "error must mention git; got: {err_msg}"
3832 );
3833 }
3834
3835 #[tokio::test]
3836 async fn test_analyze_directory_git_ref_filters_changed_files() {
3837 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
3838 use std::collections::HashSet;
3839 use tempfile::TempDir;
3840
3841 let dir = TempDir::new().unwrap();
3843 let changed_file = dir.path().join("changed.rs");
3844 let unchanged_file = dir.path().join("unchanged.rs");
3845 std::fs::write(&changed_file, "fn changed() {}").unwrap();
3846 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
3847
3848 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3849 let total_files = entries.iter().filter(|e| !e.is_dir).count();
3850 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
3851
3852 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
3854 changed.insert(changed_file.clone());
3855
3856 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
3858 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
3859
3860 assert_eq!(
3862 filtered_files.len(),
3863 1,
3864 "only 1 file must remain after git_ref filter"
3865 );
3866 assert_eq!(
3867 filtered_files[0].path, changed_file,
3868 "the remaining file must be the changed one"
3869 );
3870
3871 let _ = changed_files_from_git_ref;
3873 }
3874
3875 #[tokio::test]
3876 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
3877 use aptu_coder_core::types::{
3878 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3879 };
3880 use std::process::Command;
3881 use tempfile::TempDir;
3882
3883 let dir = TempDir::new().unwrap();
3885 let repo = dir.path();
3886
3887 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
3890 let mut cmd = std::process::Command::new("git");
3891 cmd.args(["-c", "core.hooksPath=/dev/null"]);
3892 cmd.args(args);
3893 cmd.current_dir(repo_path);
3894 let out = cmd.output().unwrap();
3895 assert!(out.status.success(), "{out:?}");
3896 };
3897 git_no_hook(repo, &["init"]);
3898 git_no_hook(
3899 repo,
3900 &[
3901 "-c",
3902 "user.email=ci@example.com",
3903 "-c",
3904 "user.name=CI",
3905 "commit",
3906 "--allow-empty",
3907 "-m",
3908 "initial",
3909 ],
3910 );
3911
3912 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
3914 git_no_hook(repo, &["add", "file_a.rs"]);
3915 git_no_hook(
3916 repo,
3917 &[
3918 "-c",
3919 "user.email=ci@example.com",
3920 "-c",
3921 "user.name=CI",
3922 "commit",
3923 "-m",
3924 "add a",
3925 ],
3926 );
3927
3928 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
3930 git_no_hook(repo, &["add", "file_b.rs"]);
3931 git_no_hook(
3932 repo,
3933 &[
3934 "-c",
3935 "user.email=ci@example.com",
3936 "-c",
3937 "user.name=CI",
3938 "commit",
3939 "-m",
3940 "add b",
3941 ],
3942 );
3943
3944 let canon_repo = std::fs::canonicalize(repo).unwrap();
3950 let analyzer = make_analyzer();
3951 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3952 "path": canon_repo.to_str().unwrap(),
3953 "git_ref": "HEAD~1",
3954 }))
3955 .unwrap();
3956 let ct = tokio_util::sync::CancellationToken::new();
3957 let (arc_output, _cache_hit) = analyzer
3958 .handle_overview_mode(¶ms, ct)
3959 .await
3960 .expect("handle_overview_mode with git_ref must succeed");
3961
3962 let formatted = &arc_output.formatted;
3964 assert!(
3965 formatted.contains("file_b.rs"),
3966 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
3967 );
3968 assert!(
3969 !formatted.contains("file_a.rs"),
3970 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
3971 );
3972 }
3973
3974 #[test]
3975 fn test_validate_path_rejects_absolute_path_outside_cwd() {
3976 let result = validate_path("/etc/passwd", true);
3979 assert!(
3980 result.is_err(),
3981 "validate_path should reject /etc/passwd (outside CWD)"
3982 );
3983 let err = result.unwrap_err();
3984 let err_msg = err.message.to_lowercase();
3985 assert!(
3986 err_msg.contains("outside") || err_msg.contains("not found"),
3987 "Error message should mention 'outside' or 'not found': {}",
3988 err.message
3989 );
3990 }
3991
3992 #[test]
3993 fn test_validate_path_accepts_relative_path_in_cwd() {
3994 let result = validate_path("Cargo.toml", true);
3997 assert!(
3998 result.is_ok(),
3999 "validate_path should accept Cargo.toml (exists in CWD)"
4000 );
4001 }
4002
4003 #[test]
4004 fn test_validate_path_creates_parent_for_nonexistent_file() {
4005 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4008 assert!(
4009 result.is_ok(),
4010 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4011 );
4012 let path = result.unwrap();
4013 let cwd = std::env::current_dir().expect("should get cwd");
4014 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4015 assert!(
4016 path.starts_with(&canonical_cwd),
4017 "Resolved path should be within CWD: {:?} should start with {:?}",
4018 path,
4019 canonical_cwd
4020 );
4021 }
4022
4023 #[test]
4024 fn test_edit_overwrite_with_working_dir() {
4025 let cwd = std::env::current_dir().expect("should get cwd");
4027 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4028 let temp_path = temp_dir.path();
4029
4030 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4032
4033 assert!(
4035 result.is_ok(),
4036 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4037 result.err()
4038 );
4039 let resolved = result.unwrap();
4040 assert!(
4041 resolved.starts_with(temp_path),
4042 "Resolved path should be within working_dir: {:?} should start with {:?}",
4043 resolved,
4044 temp_path
4045 );
4046 }
4047
4048 #[test]
4049 fn test_edit_overwrite_working_dir_traversal() {
4050 let cwd = std::env::current_dir().expect("should get cwd");
4052 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4053 let temp_path = temp_dir.path();
4054
4055 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4057
4058 assert!(
4060 result.is_err(),
4061 "validate_path_in_dir should reject path traversal outside working_dir"
4062 );
4063 let err = result.unwrap_err();
4064 let err_msg = err.message.to_lowercase();
4065 assert!(
4066 err_msg.contains("outside") || err_msg.contains("working"),
4067 "Error message should mention 'outside' or 'working': {}",
4068 err.message
4069 );
4070 }
4071
4072 #[test]
4073 fn test_edit_replace_with_working_dir() {
4074 let cwd = std::env::current_dir().expect("should get cwd");
4076 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4077 let temp_path = temp_dir.path();
4078 let file_path = temp_path.join("test.txt");
4079 std::fs::write(&file_path, "hello world").expect("should write test file");
4080
4081 let result = validate_path_in_dir("test.txt", true, temp_path);
4083
4084 assert!(
4086 result.is_ok(),
4087 "validate_path_in_dir should find existing file in working_dir: {:?}",
4088 result.err()
4089 );
4090 let resolved = result.unwrap();
4091 assert_eq!(
4092 resolved, file_path,
4093 "Resolved path should match the actual file path"
4094 );
4095 }
4096
4097 #[test]
4098 fn test_edit_overwrite_no_working_dir() {
4099 let result = validate_path("Cargo.toml", true);
4104
4105 assert!(
4107 result.is_ok(),
4108 "validate_path should still work without working_dir"
4109 );
4110 }
4111
4112 #[test]
4113 fn test_edit_overwrite_working_dir_is_file() {
4114 let cwd = std::env::current_dir().expect("should get cwd");
4116 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4117 let temp_file = temp_dir.path().join("test_file.txt");
4118 std::fs::write(&temp_file, "test content").expect("should write test file");
4119
4120 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4122
4123 assert!(
4125 result.is_err(),
4126 "validate_path_in_dir should reject a file as working_dir"
4127 );
4128 let err = result.unwrap_err();
4129 let err_msg = err.message.to_lowercase();
4130 assert!(
4131 err_msg.contains("directory"),
4132 "Error message should mention 'directory': {}",
4133 err.message
4134 );
4135 }
4136
4137 #[test]
4138 fn test_tool_annotations() {
4139 let tools = CodeAnalyzer::list_tools();
4141
4142 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4144 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4145
4146 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4148 let analyze_dir_annot = analyze_dir_tool
4149 .annotations
4150 .as_ref()
4151 .expect("analyze_directory should have annotations");
4152 assert_eq!(
4153 analyze_dir_annot.read_only_hint,
4154 Some(true),
4155 "analyze_directory read_only_hint should be true"
4156 );
4157 assert_eq!(
4158 analyze_dir_annot.destructive_hint,
4159 Some(false),
4160 "analyze_directory destructive_hint should be false"
4161 );
4162
4163 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4165 let exec_cmd_annot = exec_cmd_tool
4166 .annotations
4167 .as_ref()
4168 .expect("exec_command should have annotations");
4169 assert_eq!(
4170 exec_cmd_annot.open_world_hint,
4171 Some(true),
4172 "exec_command open_world_hint should be true"
4173 );
4174 }
4175
4176 #[test]
4177 fn test_exec_stdin_size_cap_validation() {
4178 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4181
4182 assert!(
4184 oversized_stdin.len() > STDIN_MAX_BYTES,
4185 "test setup: oversized stdin should exceed 1 MB"
4186 );
4187
4188 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4190 assert_eq!(
4191 max_stdin.len(),
4192 STDIN_MAX_BYTES,
4193 "test setup: max stdin should be exactly 1 MB"
4194 );
4195 }
4196
4197 #[tokio::test]
4198 async fn test_exec_stdin_cat_roundtrip() {
4199 let stdin_content = "hello world";
4202
4203 let mut child = tokio::process::Command::new("sh")
4205 .arg("-c")
4206 .arg("cat")
4207 .stdin(std::process::Stdio::piped())
4208 .stdout(std::process::Stdio::piped())
4209 .stderr(std::process::Stdio::piped())
4210 .spawn()
4211 .expect("spawn cat");
4212
4213 if let Some(mut stdin_handle) = child.stdin.take() {
4214 use tokio::io::AsyncWriteExt as _;
4215 stdin_handle
4216 .write_all(stdin_content.as_bytes())
4217 .await
4218 .expect("write stdin");
4219 drop(stdin_handle);
4220 }
4221
4222 let output = child.wait_with_output().await.expect("wait for cat");
4223
4224 let stdout_str = String::from_utf8_lossy(&output.stdout);
4226 assert!(
4227 stdout_str.contains(stdin_content),
4228 "stdout should contain stdin content: {}",
4229 stdout_str
4230 );
4231 }
4232
4233 #[tokio::test]
4234 async fn test_exec_stdin_none_no_regression() {
4235 let child = tokio::process::Command::new("sh")
4238 .arg("-c")
4239 .arg("echo hi")
4240 .stdin(std::process::Stdio::null())
4241 .stdout(std::process::Stdio::piped())
4242 .stderr(std::process::Stdio::piped())
4243 .spawn()
4244 .expect("spawn echo");
4245
4246 let output = child.wait_with_output().await.expect("wait for echo");
4247
4248 let stdout_str = String::from_utf8_lossy(&output.stdout);
4250 assert!(
4251 stdout_str.contains("hi"),
4252 "stdout should contain echo output: {}",
4253 stdout_str
4254 );
4255 }
4256
4257 #[test]
4258 fn test_validate_path_in_dir_rejects_sibling_prefix() {
4259 let cwd = std::env::current_dir().expect("should get cwd");
4264 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4265 let allowed = parent.path().join("allowed");
4266 let sibling = parent.path().join("allowed_sibling");
4267 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4268 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4269
4270 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4273
4274 assert!(
4276 result.is_err(),
4277 "validate_path_in_dir must reject a path resolving to a sibling directory \
4278 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4279 );
4280 let err = result.unwrap_err();
4281 let msg = err.message.to_lowercase();
4282 assert!(
4283 msg.contains("outside") || msg.contains("working"),
4284 "Error should mention 'outside' or 'working', got: {}",
4285 err.message
4286 );
4287 }
4288
4289 #[test]
4290 fn test_file_cache_capacity_default() {
4291 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4293
4294 let analyzer = make_analyzer();
4296
4297 assert_eq!(analyzer.cache.file_capacity(), 100);
4299 }
4300
4301 #[test]
4302 #[serial_test::serial]
4303 fn test_file_cache_capacity_from_env() {
4304 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
4306
4307 let analyzer = make_analyzer();
4309
4310 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4312
4313 assert_eq!(analyzer.cache.file_capacity(), 42);
4315 }
4316}