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