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