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