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