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: Astro, C/C++, C#, CSS, Fortran, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML.
25
26#![cfg_attr(test, allow(clippy::unwrap_used))]
27#![cfg_attr(test, allow(clippy::expect_used))]
28
29mod filters;
30pub(crate) mod heredoc_validation;
31pub(crate) mod metrics;
32pub(crate) mod metrics_export;
33pub(crate) mod otel;
34pub(crate) mod shell;
35pub(crate) mod shell_scan;
36/// Heredoc and shell file-write pattern detection (pre-spawn guard for exec_command).
37pub(crate) mod shell_write;
38pub(crate) mod tools;
39pub(crate) mod validation;
40
41pub use metrics::{MetricEvent, MetricsSender, MetricsWriter, migrate_legacy_metrics_dir};
42pub use otel::{
43    ClientMetadata, extract_and_set_trace_context, init_log_appender, init_meter, init_otel,
44};
45
46use aptu_coder_core::analyze;
47use aptu_coder_core::{cache, completion, types};
48use validation::validate_path;
49
50use crate::tools::common::{err_to_tool_result, no_cache_meta};
51
52pub const STDIN_MAX_BYTES: usize = 1_048_576;
53
54#[non_exhaustive]
55#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
56pub struct ExecCommandParams {
57    /// Shell command to execute via sh -c (or $SHELL if set).
58    pub command: String,
59    /// Working directory for the command. Set this instead of prepending cd to the command string. Validated against path traversal; does not sandbox the process.
60    pub working_dir: Option<String>,
61    /// UTF-8 content to pipe into the process stdin (max `STDIN_MAX_BYTES` = 1 MB). When None, stdin is closed (null).
62    pub stdin: Option<String>,
63    /// Maximum execution time in seconds. When the command exceeds this limit, the
64    /// child process is killed and the response indicates `timed_out: true`.
65    /// A value of 0 or None means no timeout (unlimited execution).
66    #[serde(default)]
67    pub timeout_secs: Option<i64>,
68    /// Drain timeout in milliseconds after the child process exits. When the child
69    /// exits but a background subprocess holds pipes open, the drain collects
70    /// buffered output for this many milliseconds before returning
71    /// `output_truncated: true`. Default: 500ms when omitted or 0.
72    /// Positive values override the default. Negative values are rejected with
73    /// INVALID_PARAMS.
74    #[serde(default)]
75    pub drain_timeout_secs: Option<i64>,
76}
77
78impl ExecCommandParams {
79    /// Creates a new ExecCommandParams with the given command.
80    #[must_use]
81    pub fn new(command: String, working_dir: Option<String>) -> Self {
82        Self {
83            command,
84            working_dir,
85            ..Default::default()
86        }
87    }
88}
89
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
91pub struct ShellOutput {
92    /// Standard output from the command.
93    pub stdout: String,
94    /// Standard error from the command.
95    pub stderr: String,
96    /// Stdout and stderr interleaved in arrival order.
97    pub interleaved: String,
98    /// Exit code; null if the process could not be waited on (e.g. drain timeout from a background process holding pipes).
99    pub exit_code: Option<i32>,
100    /// True if the post-exit drain timed out (backgrounded process kept pipes open).
101    /// When true, any available output is still included; use the overflow file path
102    /// from the truncation notice Content block to recover the full output.
103    pub output_truncated: bool,
104    /// Set when the post-exit drain timed out because a background process held the
105    /// pipes open. Distinct from `output_truncated` (size cap) -- this indicates a
106    /// drain timeout rather than a size overflow.
107    pub output_collection_error: Option<String>,
108    /// Path to the slot file containing full stdout (if output was persisted).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub stdout_path: Option<String>,
111    /// Path to the slot file containing full stderr (if output was persisted).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub stderr_path: Option<String>,
114    /// Path to the slot file containing full interleaved output (if output was persisted).
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub interleaved_path: Option<String>,
117    /// Description of the filter applied to stdout (if any).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub filter_applied: Option<String>,
120    /// True when the command was killed due to exceeding `timeout_secs`.
121    /// When true, exit_code is None and no partial output is available.
122    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123    pub timed_out: bool,
124}
125
126impl ShellOutput {
127    /// Creates a new ShellOutput with the given parameters.
128    #[must_use]
129    pub fn new(
130        stdout: String,
131        stderr: String,
132        interleaved: String,
133        exit_code: Option<i32>,
134        output_truncated: bool,
135    ) -> Self {
136        Self {
137            stdout,
138            stderr,
139            interleaved,
140            exit_code,
141            output_truncated,
142            output_collection_error: None,
143            stdout_path: None,
144            stderr_path: None,
145            interleaved_path: None,
146            filter_applied: None,
147            timed_out: false,
148        }
149    }
150}
151
152#[cfg(test)]
153use aptu_coder_core::cache::CacheTier;
154use aptu_coder_core::cache::{AnalysisCache, CallGraphCache, StructuralGraphCache};
155use aptu_coder_core::types::{
156    AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams, AnalyzeSymbolParams,
157    EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput, EditReplaceParams,
158};
159use filters::CompiledRule;
160
161use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
162use rmcp::handler::server::wrapper::Parameters;
163use rmcp::model::{
164    CacheScope, CallToolResponse, CallToolResult, CancelledNotificationParam,
165    CompleteRequestParams, CompleteResult, CompletionInfo, ContentBlock, DiscoverResult, ErrorData,
166    Implementation, InitializeRequestParams, InitializeResult, ListResourceTemplatesResult,
167    ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
168};
169use rmcp::service::{NotificationContext, RequestContext};
170use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
171
172use std::collections::HashMap;
173use std::path::Path;
174use std::sync::{Arc, Mutex};
175use tokio::sync::{Mutex as TokioMutex, RwLock};
176use tracing::instrument;
177
178static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
179
180// 5_000 chars fires at ~150-180 files at depth=2 (~28-33 chars/file).
181// Empirical data (684 calls, Jun 2026): max observed output was 4,882 chars; the old
182// 50_000 threshold never triggered once. At 5_000, auto-summary engages for repos that
183// would otherwise produce an overwhelming flat response.
184pub(crate) const SIZE_LIMIT: usize = 5_000;
185
186pub(crate) fn err_to_tool_result_from_pagination(
187    e: aptu_coder_core::pagination::PaginationError,
188) -> CallToolResult {
189    let msg = format!("Pagination error: {}", e);
190    CallToolResult::error(vec![ContentBlock::text(msg)]).with_meta(Some(no_cache_meta()))
191}
192
193/// MCP server handler that wires the four analysis tools to the rmcp transport.
194///
195/// Holds shared state: tool router, analysis cache, peer connection, log-level filter,
196/// log event channel, metrics sender, and per-session sequence tracking.
197#[derive(Clone)]
198pub struct CodeAnalyzer {
199    // Read lock acquired by list_tools/call_tool; write lock acquired during on_initialized
200    // for bind_peer_notifier.
201    // IMPORTANT: Do not perform long-running I/O while holding the write lock.
202    pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
203    cache: AnalysisCache,
204    disk_cache: std::sync::Arc<cache::DiskCache>,
205    peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
206    metrics_tx: crate::metrics::MetricsSender,
207    session_call_seq: Arc<std::sync::atomic::AtomicU32>,
208    session_id: Arc<TokioMutex<Option<String>>>,
209    client_name: Arc<TokioMutex<Option<String>>>,
210    client_version: Arc<TokioMutex<Option<String>>>,
211    // Resolved login shell PATH, captured once at startup via login shell invocation.
212    // Arc<Option<String>> is immutable after init; no lock needed.
213    resolved_path: Arc<Option<String>>,
214    // Compiled filter rules table (built-in + project-local from .aptu/filters.toml).
215    // Immutable after init; no lock needed.
216    filter_table: Arc<Vec<CompiledRule>>,
217    // L1 in-memory LRU cache for call graph results (analyze_symbol).
218    // Capacity controlled by APTU_CODER_SYMBOL_CACHE_CAPACITY env var (default 32).
219    call_graph_cache: CallGraphCache,
220    // L1 in-memory LRU cache for structural graph results (analyze_focused).
221    // Capacity controlled by APTU_CODER_STRUCTURAL_CACHE_CAPACITY env var (default 16).
222    structural_graph_cache: StructuralGraphCache,
223    // Per-(session_id, canonical_path) consecutive edit_replace failure counter.
224    // Used to detect stale LLM context and return a directive error instead of
225    // repeatedly trying an old_text that no longer matches the file content.
226    edit_failure_counts: Arc<Mutex<HashMap<(String, String), u8>>>,
227    // Per-path mutex registry for edit_replace serialization. Each path gets an
228    // Arc<Mutex<()>> that is acquired inside spawn_blocking to prevent concurrent
229    // read-modify-write cycles on the same file (silent data loss prevention).
230    file_edit_locks: tools::FileEditLockRegistry,
231    // On-disk graph store for structural knowledge graph resources.
232    graph_store: std::sync::Arc<aptu_coder_core::graph::GraphDiskStore>,
233}
234
235#[tool_router]
236impl CodeAnalyzer {
237    #[must_use]
238    pub fn list_tools() -> Vec<rmcp::model::Tool> {
239        Self::tool_router().list_all()
240    }
241
242    pub fn new(
243        peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
244        metrics_tx: crate::metrics::MetricsSender,
245    ) -> Self {
246        crate::tools::server::build_analyzer(peer, metrics_tx)
247    }
248
249    /// Emit a "received" metric event for the given tool name.
250    /// Increments the session call sequence, locks the session ID, and sends
251    /// the metric event via the channel. Returns the (seq, sid) pair for use
252    /// by the caller in exit metrics, preserving per-call seq uniqueness.
253    async fn emit_received_metric(&self, tool: &'static str) -> (u32, Option<String>) {
254        crate::tools::server::emit_received_metric(
255            &self.metrics_tx,
256            &self.session_id,
257            &self.session_call_seq,
258            tool,
259        )
260        .await
261    }
262
263    /// Delegates to [`tools::server::handle_overview_mode`].
264    /// Kept for test access; production path goes through `analyze_directory` shim.
265    #[cfg(test)]
266    pub(crate) async fn handle_overview_mode(
267        &self,
268        params: &AnalyzeDirectoryParams,
269        ct: tokio_util::sync::CancellationToken,
270    ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
271        let ctx = crate::tools::AnalyzeDirectoryContext {
272            cache: self.cache.clone(),
273            disk_cache: self.disk_cache.clone(),
274            metrics_tx: self.metrics_tx.clone(),
275            peer: self.peer.clone(),
276            sid: self.session_id.lock().await.clone(),
277        };
278        crate::tools::server::handle_overview_mode(&ctx, params, ct).await
279    }
280
281    /// Delegates to [`tools::server::handle_file_details_mode`].
282    /// Kept for test access; production path goes through `analyze_file` shim.
283    #[cfg(test)]
284    pub(crate) async fn handle_file_details_mode(
285        &self,
286        params: &aptu_coder_core::types::AnalyzeFileParams,
287    ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
288        crate::tools::server::handle_file_details_mode(
289            self.cache.clone(),
290            self.disk_cache.clone(),
291            self.metrics_tx.clone(),
292            self.session_id.lock().await.clone(),
293            params,
294        )
295        .await
296    }
297
298    #[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))]
299    #[tool(
300        name = "analyze_directory",
301        title = "Analyze Directory",
302        description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Paginates with next_cursor. Default max_depth=3; pass 0 for unlimited. Large dirs (1000+ files) auto-compact to summary; pass summary=false for per-file list (summary and cursor are mutually exclusive). git_ref restricts to files changed since a branch/tag/commit. Empty directories return zero counts.",
303        output_schema = schema_for_type::<analyze::AnalysisOutput>(),
304        annotations(
305            title = "Analyze Directory",
306            read_only_hint = true,
307            destructive_hint = false,
308            idempotent_hint = true,
309            open_world_hint = false
310        )
311    )]
312    async fn analyze_directory(
313        &self,
314        params: Parameters<AnalyzeDirectoryParams>,
315        context: RequestContext<RoleServer>,
316    ) -> Result<CallToolResult, ErrorData> {
317        let mut params = params.0;
318        params.max_depth = params.max_depth.or(Some(3));
319        let t_start = std::time::Instant::now();
320        let (seq, sid) = self.emit_received_metric("analyze_directory").await;
321        let session_id = self.session_id.lock().await.clone();
322        let client_name = self.client_name.lock().await.clone();
323        let client_version = self.client_version.lock().await.clone();
324        extract_and_set_trace_context(
325            Some(&context.meta),
326            ClientMetadata {
327                session_id,
328                client_name,
329                client_version,
330            },
331        );
332        let span = tracing::Span::current();
333        span.record("gen_ai.system", "mcp");
334        span.record("gen_ai.operation.name", "execute_tool");
335        span.record("gen_ai.tool.name", "analyze_directory");
336        span.record("path", &params.path);
337        let _validated_path = match validate_path(&params.path, true) {
338            Ok(p) => p,
339            Err(e) => {
340                span.record("error", true);
341                span.record("error.type", "invalid_params");
342                return Ok(err_to_tool_result(e));
343            }
344        };
345        let ct = context.ct.clone();
346        let param_path = params.path.clone();
347        let max_depth_val = params.max_depth;
348        let ctx = tools::AnalyzeDirectoryContext {
349            cache: self.cache.clone(),
350            disk_cache: self.disk_cache.clone(),
351            metrics_tx: self.metrics_tx.clone(),
352            peer: self.peer.clone(),
353            sid: sid.clone(),
354        };
355        tools::analyze_directory::analyze_directory_handler(
356            &ctx,
357            params,
358            tools::DirectoryHandlerCall {
359                seq,
360                sid,
361                t_start,
362                param_path,
363                max_depth_val,
364                ct,
365            },
366            &span,
367        )
368        .await
369    }
370
371    #[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))]
372    #[tool(
373        name = "analyze_file",
374        title = "Analyze File",
375        description = "Functions, types, classes, and imports from a single source file. Fails if directory path supplied; use analyze_directory instead. Paginates with cursor/page_size; use fields=[\"functions\",\"classes\",\"imports\"] to limit sections. summary=true and cursor are mutually exclusive. git_ref not supported. Use analyze_module for a lightweight function/import index (~75% smaller). Supported: Astro, C/C++, C#, CSS, Fortran, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML.",
376        output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
377        annotations(
378            title = "Analyze File",
379            read_only_hint = true,
380            destructive_hint = false,
381            idempotent_hint = true,
382            open_world_hint = false
383        )
384    )]
385    async fn analyze_file(
386        &self,
387        params: Parameters<AnalyzeFileParams>,
388        context: RequestContext<RoleServer>,
389    ) -> Result<CallToolResult, ErrorData> {
390        let params = params.0;
391        let t_start = std::time::Instant::now();
392        let (seq, sid) = self.emit_received_metric("analyze_file").await;
393        let session_id = self.session_id.lock().await.clone();
394        let client_name = self.client_name.lock().await.clone();
395        let client_version = self.client_version.lock().await.clone();
396        extract_and_set_trace_context(
397            Some(&context.meta),
398            ClientMetadata {
399                session_id,
400                client_name,
401                client_version,
402            },
403        );
404        let span = tracing::Span::current();
405        span.record("gen_ai.system", "mcp");
406        span.record("gen_ai.operation.name", "execute_tool");
407        span.record("gen_ai.tool.name", "analyze_file");
408        span.record("path", &params.path);
409        let _validated_path = match validate_path(&params.path, true) {
410            Ok(p) => p,
411            Err(e) => {
412                span.record("error", true);
413                span.record("error.type", "invalid_params");
414                return Ok(err_to_tool_result(e));
415            }
416        };
417        let param_path = params.path.clone();
418        let ctx = tools::AnalyzeFileContext {
419            cache: self.cache.clone(),
420            disk_cache: self.disk_cache.clone(),
421            metrics_tx: self.metrics_tx.clone(),
422            sid: sid.clone(),
423        };
424        tools::analyze_file::analyze_file_handler(
425            &ctx, params, seq, sid, t_start, param_path, &span,
426        )
427        .await
428    }
429
430    #[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))]
431    #[tool(
432        name = "analyze_symbol",
433        title = "Analyze Symbol",
434        description = "Call graph for a named symbol across all files in a directory. Use for \"who calls X\", transitive chains, or 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\". 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.",
435        output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
436        annotations(
437            title = "Analyze Symbol",
438            read_only_hint = true,
439            destructive_hint = false,
440            idempotent_hint = true,
441            open_world_hint = false
442        )
443    )]
444    async fn analyze_symbol(
445        &self,
446        params: Parameters<AnalyzeSymbolParams>,
447        context: RequestContext<RoleServer>,
448    ) -> Result<CallToolResult, ErrorData> {
449        let params = params.0;
450        let t_start = std::time::Instant::now();
451        let (seq, sid) = self.emit_received_metric("analyze_symbol").await;
452        let session_id = self.session_id.lock().await.clone();
453        let client_name = self.client_name.lock().await.clone();
454        let client_version = self.client_version.lock().await.clone();
455        extract_and_set_trace_context(
456            Some(&context.meta),
457            ClientMetadata {
458                session_id,
459                client_name,
460                client_version,
461            },
462        );
463        let span = tracing::Span::current();
464        span.record("gen_ai.system", "mcp");
465        span.record("gen_ai.operation.name", "execute_tool");
466        span.record("gen_ai.tool.name", "analyze_symbol");
467        span.record("symbol", &params.symbol);
468        let _validated_path = match validate_path(&params.path, true) {
469            Ok(p) => p,
470            Err(e) => {
471                span.record("error", true);
472                span.record("error.type", "invalid_params");
473                return Ok(err_to_tool_result(e));
474            }
475        };
476        let ct = context.ct.clone();
477        let param_path = params.path.clone();
478        let max_depth_val = params.follow_depth;
479        let ctx = tools::AnalyzeSymbolContext {
480            metrics_tx: self.metrics_tx.clone(),
481            call_graph_cache: self.call_graph_cache.clone(),
482            structural_graph_cache: self.structural_graph_cache.clone(),
483            disk_cache: self.disk_cache.clone(),
484            sid: sid.clone(),
485            seq,
486        };
487        let call = tools::AnalyzeSymbolCall {
488            ct,
489            param_path,
490            max_depth_val,
491            span,
492            t_start,
493        };
494        tools::analyze_symbol::analyze_symbol_handler(ctx, params, call).await
495    }
496
497    #[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))]
498    #[tool(
499        name = "analyze_module",
500        title = "Analyze Module",
501        description = "Lightweight 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 and git_ref not supported. Use analyze_file for signatures, types, or class details. Supported: Astro, C/C++, C#, CSS, Fortran, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML.",
502        output_schema = schema_for_type::<types::ModuleInfo>(),
503        annotations(
504            title = "Analyze Module",
505            read_only_hint = true,
506            destructive_hint = false,
507            idempotent_hint = true,
508            open_world_hint = false
509        )
510    )]
511    async fn analyze_module(
512        &self,
513        params: Parameters<AnalyzeModuleParams>,
514        context: RequestContext<RoleServer>,
515    ) -> Result<CallToolResult, ErrorData> {
516        let params = params.0;
517        let t_start = std::time::Instant::now();
518        let (seq, sid) = self.emit_received_metric("analyze_module").await;
519        let session_id = self.session_id.lock().await.clone();
520        let client_name = self.client_name.lock().await.clone();
521        let client_version = self.client_version.lock().await.clone();
522        extract_and_set_trace_context(
523            Some(&context.meta),
524            ClientMetadata {
525                session_id,
526                client_name,
527                client_version,
528            },
529        );
530        let span = tracing::Span::current();
531        span.record("gen_ai.system", "mcp");
532        span.record("gen_ai.operation.name", "execute_tool");
533        span.record("gen_ai.tool.name", "analyze_module");
534        span.record("path", &params.path);
535        let _validated_path = match validate_path(&params.path, true) {
536            Ok(p) => p,
537            Err(e) => {
538                span.record("error", true);
539                span.record("error.type", "invalid_params");
540                return Ok(err_to_tool_result(e));
541            }
542        };
543        let param_path = params.path.clone();
544        let ctx = tools::AnalyzeModuleContext {
545            disk_cache: self.disk_cache.clone(),
546            metrics_tx: self.metrics_tx.clone(),
547            sid: sid.clone(),
548            seq,
549        };
550        tools::analyze_module::analyze_module_handler(ctx, params, param_path, &span, t_start).await
551    }
552
553    #[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))]
554    #[tool(
555        name = "edit_overwrite",
556        title = "Edit Overwrite",
557        description = "Creates or overwrites a file with UTF-8 content; creates parent directories if needed. Works on any file type. Use edit_replace for targeted single-block edits. working_dir sets the base directory for path resolution (default: server CWD).",
558        output_schema = schema_for_type::<EditOverwriteOutput>(),
559        annotations(
560            title = "Edit Overwrite",
561            read_only_hint = false,
562            destructive_hint = true,
563            idempotent_hint = false,
564            open_world_hint = false
565        )
566    )]
567    async fn edit_overwrite(
568        &self,
569        params: Parameters<EditOverwriteParams>,
570        context: RequestContext<RoleServer>,
571    ) -> Result<CallToolResult, ErrorData> {
572        let params = params.0;
573        let t_start = std::time::Instant::now();
574        let (seq, sid) = self.emit_received_metric("edit_overwrite").await;
575        // Extract W3C Trace Context from request _meta if present
576        let session_id = self.session_id.lock().await.clone();
577        let client_name = self.client_name.lock().await.clone();
578        let client_version = self.client_version.lock().await.clone();
579        extract_and_set_trace_context(
580            Some(&context.meta),
581            ClientMetadata {
582                session_id,
583                client_name,
584                client_version,
585            },
586        );
587        let span = tracing::Span::current();
588        span.record("gen_ai.system", "mcp");
589        span.record("gen_ai.operation.name", "execute_tool");
590        tools::edit_overwrite::edit_overwrite(
591            params,
592            tools::EditHandlerContext {
593                sid,
594                seq,
595                cache: &self.cache,
596                metrics_tx: &self.metrics_tx,
597                edit_failure_counts: &self.edit_failure_counts,
598                file_edit_locks: &self.file_edit_locks,
599            },
600            &span,
601            t_start,
602        )
603        .await
604    }
605
606    #[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))]
607    #[tool(
608        name = "edit_replace",
609        title = "Edit Replace",
610        description = "Replaces an exact text block; old_text must appear exactly once. Fails if zero or multiple matches (extend old_text to disambiguate). Set replace_all=true to replace every non-overlapping occurrence; old_text must be non-empty. Pass empty new_text to delete. CRLF in old_text normalized to LF; all other whitespace matched exactly. Pass expected_content_hash (blake3 hex of raw file bytes) to detect stale context; a mismatch rejects the edit. On invalid_params, re-read with analyze_file or analyze_module and retry. Use edit_overwrite to replace the whole file. working_dir sets the base directory for path resolution (default: server CWD).",
611        output_schema = schema_for_type::<EditReplaceOutput>(),
612        annotations(
613            title = "Edit Replace",
614            read_only_hint = false,
615            destructive_hint = true,
616            idempotent_hint = false,
617            open_world_hint = false
618        )
619    )]
620    async fn edit_replace(
621        &self,
622        params: Parameters<EditReplaceParams>,
623        context: RequestContext<RoleServer>,
624    ) -> Result<CallToolResult, ErrorData> {
625        let params = params.0;
626        let t_start = std::time::Instant::now();
627        let (seq, sid) = self.emit_received_metric("edit_replace").await;
628        // Extract W3C Trace Context from request _meta if present
629        let session_id = self.session_id.lock().await.clone();
630        let client_name = self.client_name.lock().await.clone();
631        let client_version = self.client_version.lock().await.clone();
632        extract_and_set_trace_context(
633            Some(&context.meta),
634            ClientMetadata {
635                session_id,
636                client_name,
637                client_version,
638            },
639        );
640        let span = tracing::Span::current();
641        span.record("gen_ai.system", "mcp");
642        span.record("gen_ai.operation.name", "execute_tool");
643        tools::edit_replace::edit_replace(
644            params,
645            tools::EditHandlerContext {
646                sid,
647                seq,
648                cache: &self.cache,
649                metrics_tx: &self.metrics_tx,
650                edit_failure_counts: &self.edit_failure_counts,
651                file_edit_locks: &self.file_edit_locks,
652            },
653            &span,
654            t_start,
655        )
656        .await
657    }
658
659    #[tool(
660        name = "exec_command",
661        title = "Exec Command",
662        description = "Execute shell command via sh -c (or $SHELL if set). Output capped at 30 KB stdout / 10 KB stderr / 2000 lines. Set working_dir to the target directory; write commands with relative paths only. Pass stdin to pipe UTF-8 content (max 1 MB); heredoc syntax is rejected. For file writes use edit_overwrite or edit_replace. Prefer machine-readable output flags (e.g. --json) to reduce tokens.",
663        output_schema = schema_for_type::<ShellOutput>(),
664        annotations(
665            title = "Exec Command",
666            read_only_hint = false,
667            destructive_hint = true,
668            idempotent_hint = false,
669            open_world_hint = true
670        )
671    )]
672    #[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, 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))]
673    pub async fn exec_command(
674        &self,
675        params: Parameters<ExecCommandParams>,
676        context: RequestContext<RoleServer>,
677    ) -> Result<CallToolResult, ErrorData> {
678        let t_start = std::time::Instant::now();
679        let (seq, sid) = self.emit_received_metric("exec_command").await;
680        let params = params.0;
681        let session_id = self.session_id.lock().await.clone();
682        let client_name = self.client_name.lock().await.clone();
683        let client_version = self.client_version.lock().await.clone();
684        let ctx = crate::tools::exec_command::ExecContext {
685            seq,
686            sid,
687            session_id,
688            client_name,
689            client_version,
690            resolved_path: self.resolved_path.as_ref().as_deref().map(str::to_owned),
691            filter_table: self.filter_table.clone(),
692            metrics_tx: self.metrics_tx.clone(),
693            t_start,
694        };
695        crate::tools::exec_command::exec_command_impl(params, context, ctx).await
696    }
697}
698
699#[tool_handler]
700impl ServerHandler for CodeAnalyzer {
701    #[instrument(skip(self, _context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
702    async fn initialize(
703        &self,
704        request: InitializeRequestParams,
705        _context: RequestContext<RoleServer>,
706    ) -> Result<InitializeResult, ErrorData> {
707        let span = tracing::Span::current();
708        span.record("service.name", "aptu-coder");
709        span.record("service.version", env!("CARGO_PKG_VERSION"));
710
711        // Store client_info from the initialize request
712        {
713            let mut client_name_lock = self.client_name.lock().await;
714            *client_name_lock = Some(request.client_info.name.clone());
715        }
716        {
717            let mut client_version_lock = self.client_version.lock().await;
718            *client_version_lock = Some(request.client_info.version.clone());
719        }
720        Ok(self.get_info())
721    }
722
723    /// Returns server discovery information including supported protocol versions
724    /// and capabilities.
725    async fn discover(
726        &self,
727        _context: RequestContext<RoleServer>,
728    ) -> Result<DiscoverResult, ErrorData> {
729        Ok(DiscoverResult::from_server_info(
730            self.supported_protocol_versions().into_owned(),
731            self.get_info(),
732        ))
733    }
734
735    fn get_info(&self) -> InitializeResult {
736        let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
737        let instructions = format!(
738            "Recommended workflow:\n\
739            1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
740            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\
741            3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
742            4. Use analyze_symbol to trace call graphs.\n\
743            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.\n\
744            JSONL metrics at $HOME/.local/share/aptu-coder/ (or $XDG_DATA_HOME/aptu-coder/). Always cd there before jq glob queries."
745        );
746        let capabilities = ServerCapabilities::builder()
747            .enable_tools()
748            .enable_tool_list_changed()
749            .enable_completions()
750            .enable_resources()
751            .build();
752        let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
753            .with_title("Aptu Coder")
754            .with_description("MCP server for code structure analysis using tree-sitter");
755        InitializeResult::new(capabilities)
756            .with_server_info(server_info)
757            .with_instructions(&instructions)
758    }
759
760    async fn list_tools(
761        &self,
762        _request: Option<rmcp::model::PaginatedRequestParams>,
763        _context: RequestContext<RoleServer>,
764    ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
765        let router = self.tool_router.read().await;
766        Ok(
767            rmcp::model::ListToolsResult::with_all_items(router.list_all())
768                .with_ttl_ms(3_600_000)
769                .with_cache_scope(CacheScope::Public),
770        )
771    }
772
773    async fn call_tool(
774        &self,
775        request: rmcp::model::CallToolRequestParams,
776        context: RequestContext<RoleServer>,
777    ) -> Result<CallToolResponse, ErrorData> {
778        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
779        let router = self.tool_router.read().await;
780        router.call(tcc).await
781    }
782
783    async fn list_resources(
784        &self,
785        _request: Option<rmcp::model::PaginatedRequestParams>,
786        _context: RequestContext<RoleServer>,
787    ) -> Result<ListResourcesResult, ErrorData> {
788        tools::resources::list_resources_impl(_request, &_context)
789    }
790
791    async fn list_resource_templates(
792        &self,
793        _request: Option<rmcp::model::PaginatedRequestParams>,
794        _context: RequestContext<RoleServer>,
795    ) -> Result<ListResourceTemplatesResult, ErrorData> {
796        tools::resources::list_resource_templates_impl(_request, &_context)
797    }
798
799    async fn read_resource(
800        &self,
801        request: ReadResourceRequestParams,
802        _context: RequestContext<RoleServer>,
803    ) -> Result<ReadResourceResponse, ErrorData> {
804        tools::resources::read_resource_impl(request, &self.graph_store)
805    }
806
807    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
808        crate::tools::server::on_initialized_impl(
809            self.peer.clone(),
810            self.session_id.clone(),
811            self.session_call_seq.clone(),
812            self.tool_router.clone(),
813            &context.peer,
814        )
815        .await;
816    }
817
818    #[instrument(skip(self, _context))]
819    async fn on_cancelled(
820        &self,
821        notification: CancelledNotificationParam,
822        _context: NotificationContext<RoleServer>,
823    ) {
824        tracing::info!(
825            request_id = ?notification.request_id,
826            reason = ?notification.reason,
827            "Received cancellation notification"
828        );
829    }
830
831    #[instrument(skip(self, _context))]
832    async fn complete(
833        &self,
834        request: CompleteRequestParams,
835        _context: RequestContext<RoleServer>,
836    ) -> Result<CompleteResult, ErrorData> {
837        // Dispatch on argument name: "path" or "symbol"
838        let argument_name = &request.argument.name;
839        let argument_value = &request.argument.value;
840
841        let completions = match argument_name.as_str() {
842            "path" => {
843                // Path completions: use current directory as root
844                let root = Path::new(".");
845                completion::path_completions(root, argument_value)
846            }
847            "symbol" => {
848                // Symbol completions: need the path argument from context
849                let path_arg = request
850                    .context
851                    .as_ref()
852                    .and_then(|ctx| ctx.get_argument("path"));
853
854                match path_arg {
855                    Some(path_str) => {
856                        let path = Path::new(path_str);
857                        completion::symbol_completions(&self.cache, path, argument_value)
858                    }
859                    None => Vec::new(),
860                }
861            }
862            _ => Vec::new(),
863        };
864
865        // Create CompletionInfo with has_more flag if >100 results
866        let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
867        let (values, has_more) = if completions.len() > 100 {
868            (completions.into_iter().take(100).collect(), true)
869        } else {
870            (completions, false)
871        };
872
873        let completion_info =
874            match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
875                Ok(info) => info,
876                Err(_) => {
877                    // Graceful degradation: return empty on error
878                    CompletionInfo::with_all_values(Vec::new())
879                        .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
880                }
881            };
882
883        Ok(CompleteResult::new(completion_info))
884    }
885}
886
887#[cfg(test)]
888mod tests;