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