Skip to main content

aptu_coder/
lib.rs

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