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