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