1#![cfg_attr(test, allow(clippy::unwrap_used))]
27#![cfg_attr(test, allow(clippy::expect_used))]
28
29mod filters;
30pub(crate) mod heredoc_validation;
31pub(crate) mod metrics;
32pub(crate) mod metrics_export;
33pub(crate) mod otel;
34pub(crate) mod shell;
35pub(crate) mod shell_scan;
36pub(crate) mod shell_write;
38pub(crate) mod tools;
39pub(crate) mod validation;
40
41pub use metrics::{MetricEvent, MetricsSender, MetricsWriter, migrate_legacy_metrics_dir};
42pub use otel::{
43 ClientMetadata, extract_and_set_trace_context, init_log_appender, init_meter, init_otel,
44};
45
46use aptu_coder_core::analyze;
47use aptu_coder_core::{cache, completion, types};
48use validation::validate_path;
49
50use crate::tools::common::{err_to_tool_result, no_cache_meta};
51
52pub const STDIN_MAX_BYTES: usize = 1_048_576;
53
54#[non_exhaustive]
55#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
56pub struct ExecCommandParams {
57 pub command: String,
59 pub working_dir: Option<String>,
61 pub stdin: Option<String>,
63 #[serde(default)]
67 pub timeout_secs: Option<i64>,
68 #[serde(default)]
75 pub drain_timeout_secs: Option<i64>,
76}
77
78impl ExecCommandParams {
79 #[must_use]
81 pub fn new(command: String, working_dir: Option<String>) -> Self {
82 Self {
83 command,
84 working_dir,
85 ..Default::default()
86 }
87 }
88}
89
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
91pub struct ShellOutput {
92 pub stdout: String,
94 pub stderr: String,
96 pub interleaved: String,
98 pub exit_code: Option<i32>,
100 pub output_truncated: bool,
104 pub output_collection_error: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub stdout_path: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub stderr_path: Option<String>,
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub interleaved_path: Option<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub filter_applied: Option<String>,
120 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
123 pub timed_out: bool,
124}
125
126impl ShellOutput {
127 #[must_use]
129 pub fn new(
130 stdout: String,
131 stderr: String,
132 interleaved: String,
133 exit_code: Option<i32>,
134 output_truncated: bool,
135 ) -> Self {
136 Self {
137 stdout,
138 stderr,
139 interleaved,
140 exit_code,
141 output_truncated,
142 output_collection_error: None,
143 stdout_path: None,
144 stderr_path: None,
145 interleaved_path: None,
146 filter_applied: None,
147 timed_out: false,
148 }
149 }
150}
151
152#[cfg(test)]
153use aptu_coder_core::cache::CacheTier;
154use aptu_coder_core::cache::{AnalysisCache, CallGraphCache, StructuralGraphCache};
155use aptu_coder_core::types::{
156 AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams, AnalyzeSymbolParams,
157 EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput, EditReplaceParams,
158};
159use filters::CompiledRule;
160
161use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
162use rmcp::handler::server::wrapper::Parameters;
163use rmcp::model::{
164 CacheScope, CallToolResponse, CallToolResult, CancelledNotificationParam,
165 CompleteRequestParams, CompleteResult, CompletionInfo, ContentBlock, DiscoverResult, ErrorData,
166 Implementation, InitializeRequestParams, InitializeResult, ListResourceTemplatesResult,
167 ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
168};
169use rmcp::service::{NotificationContext, RequestContext};
170use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
171
172use std::collections::HashMap;
173use std::path::Path;
174use std::sync::{Arc, Mutex};
175use tokio::sync::{Mutex as TokioMutex, RwLock};
176use tracing::instrument;
177
178static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
179
180pub(crate) const SIZE_LIMIT: usize = 5_000;
185
186pub(crate) fn err_to_tool_result_from_pagination(
187 e: aptu_coder_core::pagination::PaginationError,
188) -> CallToolResult {
189 let msg = format!("Pagination error: {}", e);
190 CallToolResult::error(vec![ContentBlock::text(msg)]).with_meta(Some(no_cache_meta()))
191}
192
193#[derive(Clone)]
198pub struct CodeAnalyzer {
199 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
203 cache: AnalysisCache,
204 disk_cache: std::sync::Arc<cache::DiskCache>,
205 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
206 metrics_tx: crate::metrics::MetricsSender,
207 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
208 session_id: Arc<TokioMutex<Option<String>>>,
209 client_name: Arc<TokioMutex<Option<String>>>,
210 client_version: Arc<TokioMutex<Option<String>>>,
211 resolved_path: Arc<Option<String>>,
214 filter_table: Arc<Vec<CompiledRule>>,
217 call_graph_cache: CallGraphCache,
220 structural_graph_cache: StructuralGraphCache,
223 edit_failure_counts: Arc<Mutex<HashMap<(String, String), u8>>>,
227 file_edit_locks: tools::FileEditLockRegistry,
231 graph_store: std::sync::Arc<aptu_coder_core::graph::GraphDiskStore>,
233}
234
235#[tool_router]
236impl CodeAnalyzer {
237 #[must_use]
238 pub fn list_tools() -> Vec<rmcp::model::Tool> {
239 Self::tool_router().list_all()
240 }
241
242 pub fn new(
243 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
244 metrics_tx: crate::metrics::MetricsSender,
245 ) -> Self {
246 crate::tools::server::build_analyzer(peer, metrics_tx)
247 }
248
249 async fn emit_received_metric(&self, tool: &'static str) -> (u32, Option<String>) {
254 crate::tools::server::emit_received_metric(
255 &self.metrics_tx,
256 &self.session_id,
257 &self.session_call_seq,
258 tool,
259 )
260 .await
261 }
262
263 #[cfg(test)]
266 pub(crate) async fn handle_overview_mode(
267 &self,
268 params: &AnalyzeDirectoryParams,
269 ct: tokio_util::sync::CancellationToken,
270 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
271 let ctx = crate::tools::AnalyzeDirectoryContext {
272 cache: self.cache.clone(),
273 disk_cache: self.disk_cache.clone(),
274 metrics_tx: self.metrics_tx.clone(),
275 peer: self.peer.clone(),
276 sid: self.session_id.lock().await.clone(),
277 };
278 crate::tools::server::handle_overview_mode(&ctx, params, ct).await
279 }
280
281 #[cfg(test)]
284 pub(crate) async fn handle_file_details_mode(
285 &self,
286 params: &aptu_coder_core::types::AnalyzeFileParams,
287 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
288 crate::tools::server::handle_file_details_mode(
289 self.cache.clone(),
290 self.disk_cache.clone(),
291 self.metrics_tx.clone(),
292 self.session_id.lock().await.clone(),
293 params,
294 )
295 .await
296 }
297
298 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
299 #[tool(
300 name = "analyze_directory",
301 title = "Analyze Directory",
302 description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Paginates with next_cursor. Default max_depth=3; pass 0 for unlimited. Large dirs (1000+ files) auto-compact to summary; pass summary=false for per-file list (summary and cursor are mutually exclusive). git_ref restricts to files changed since a branch/tag/commit. Empty directories return zero counts.",
303 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
304 annotations(
305 title = "Analyze Directory",
306 read_only_hint = true,
307 destructive_hint = false,
308 idempotent_hint = true,
309 open_world_hint = false
310 )
311 )]
312 async fn analyze_directory(
313 &self,
314 params: Parameters<AnalyzeDirectoryParams>,
315 context: RequestContext<RoleServer>,
316 ) -> Result<CallToolResult, ErrorData> {
317 let mut params = params.0;
318 params.max_depth = params.max_depth.or(Some(3));
319 let t_start = std::time::Instant::now();
320 let (seq, sid) = self.emit_received_metric("analyze_directory").await;
321 let session_id = self.session_id.lock().await.clone();
322 let client_name = self.client_name.lock().await.clone();
323 let client_version = self.client_version.lock().await.clone();
324 extract_and_set_trace_context(
325 Some(&context.meta),
326 ClientMetadata {
327 session_id,
328 client_name,
329 client_version,
330 },
331 );
332 let span = tracing::Span::current();
333 span.record("gen_ai.system", "mcp");
334 span.record("gen_ai.operation.name", "execute_tool");
335 span.record("gen_ai.tool.name", "analyze_directory");
336 span.record("path", ¶ms.path);
337 let _validated_path = match validate_path(¶ms.path, true) {
338 Ok(p) => p,
339 Err(e) => {
340 span.record("error", true);
341 span.record("error.type", "invalid_params");
342 return Ok(err_to_tool_result(e));
343 }
344 };
345 let ct = context.ct.clone();
346 let param_path = params.path.clone();
347 let max_depth_val = params.max_depth;
348 let ctx = tools::AnalyzeDirectoryContext {
349 cache: self.cache.clone(),
350 disk_cache: self.disk_cache.clone(),
351 metrics_tx: self.metrics_tx.clone(),
352 peer: self.peer.clone(),
353 sid: sid.clone(),
354 };
355 tools::analyze_directory::analyze_directory_handler(
356 &ctx,
357 params,
358 tools::DirectoryHandlerCall {
359 seq,
360 sid,
361 t_start,
362 param_path,
363 max_depth_val,
364 ct,
365 },
366 &span,
367 )
368 .await
369 }
370
371 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
372 #[tool(
373 name = "analyze_file",
374 title = "Analyze File",
375 description = "Functions, types, classes, and imports from a single source file. Fails if directory path supplied; use analyze_directory instead. Paginates with cursor/page_size; use fields=[\"functions\",\"classes\",\"imports\"] to limit sections. summary=true and cursor are mutually exclusive. git_ref not supported. Use analyze_module for a lightweight function/import index (~75% smaller). Supported: Astro, C/C++, C#, CSS, Fortran, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML.",
376 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
377 annotations(
378 title = "Analyze File",
379 read_only_hint = true,
380 destructive_hint = false,
381 idempotent_hint = true,
382 open_world_hint = false
383 )
384 )]
385 async fn analyze_file(
386 &self,
387 params: Parameters<AnalyzeFileParams>,
388 context: RequestContext<RoleServer>,
389 ) -> Result<CallToolResult, ErrorData> {
390 let params = params.0;
391 let t_start = std::time::Instant::now();
392 let (seq, sid) = self.emit_received_metric("analyze_file").await;
393 let session_id = self.session_id.lock().await.clone();
394 let client_name = self.client_name.lock().await.clone();
395 let client_version = self.client_version.lock().await.clone();
396 extract_and_set_trace_context(
397 Some(&context.meta),
398 ClientMetadata {
399 session_id,
400 client_name,
401 client_version,
402 },
403 );
404 let span = tracing::Span::current();
405 span.record("gen_ai.system", "mcp");
406 span.record("gen_ai.operation.name", "execute_tool");
407 span.record("gen_ai.tool.name", "analyze_file");
408 span.record("path", ¶ms.path);
409 let _validated_path = match validate_path(¶ms.path, true) {
410 Ok(p) => p,
411 Err(e) => {
412 span.record("error", true);
413 span.record("error.type", "invalid_params");
414 return Ok(err_to_tool_result(e));
415 }
416 };
417 let param_path = params.path.clone();
418 let ctx = tools::AnalyzeFileContext {
419 cache: self.cache.clone(),
420 disk_cache: self.disk_cache.clone(),
421 metrics_tx: self.metrics_tx.clone(),
422 sid: sid.clone(),
423 };
424 tools::analyze_file::analyze_file_handler(
425 &ctx, params, seq, sid, t_start, param_path, &span,
426 )
427 .await
428 }
429
430 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, symbol = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
431 #[tool(
432 name = "analyze_symbol",
433 title = "Analyze Symbol",
434 description = "Call graph for a named symbol across all files in a directory. Use for \"who calls X\", transitive chains, or files importing a module path. Prefer over analyze_file when the question is \"who calls X\" or \"what does X call\" rather than \"what is in this file\". Modes: call graph (default), import_lookup (files importing a module path), def_use (write/read sites). Fails if file path supplied; fails if impl_only=true on non-Rust directory; fails if import_lookup=true with empty symbol; fails if summary=true and cursor. match_mode controls name matching (exact/insensitive/prefix/contains). git_ref restricts to changed files.",
435 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
436 annotations(
437 title = "Analyze Symbol",
438 read_only_hint = true,
439 destructive_hint = false,
440 idempotent_hint = true,
441 open_world_hint = false
442 )
443 )]
444 async fn analyze_symbol(
445 &self,
446 params: Parameters<AnalyzeSymbolParams>,
447 context: RequestContext<RoleServer>,
448 ) -> Result<CallToolResult, ErrorData> {
449 let params = params.0;
450 let t_start = std::time::Instant::now();
451 let (seq, sid) = self.emit_received_metric("analyze_symbol").await;
452 let session_id = self.session_id.lock().await.clone();
453 let client_name = self.client_name.lock().await.clone();
454 let client_version = self.client_version.lock().await.clone();
455 extract_and_set_trace_context(
456 Some(&context.meta),
457 ClientMetadata {
458 session_id,
459 client_name,
460 client_version,
461 },
462 );
463 let span = tracing::Span::current();
464 span.record("gen_ai.system", "mcp");
465 span.record("gen_ai.operation.name", "execute_tool");
466 span.record("gen_ai.tool.name", "analyze_symbol");
467 span.record("symbol", ¶ms.symbol);
468 let _validated_path = match validate_path(¶ms.path, true) {
469 Ok(p) => p,
470 Err(e) => {
471 span.record("error", true);
472 span.record("error.type", "invalid_params");
473 return Ok(err_to_tool_result(e));
474 }
475 };
476 let ct = context.ct.clone();
477 let param_path = params.path.clone();
478 let max_depth_val = params.follow_depth;
479 let ctx = tools::AnalyzeSymbolContext {
480 metrics_tx: self.metrics_tx.clone(),
481 call_graph_cache: self.call_graph_cache.clone(),
482 structural_graph_cache: self.structural_graph_cache.clone(),
483 disk_cache: self.disk_cache.clone(),
484 sid: sid.clone(),
485 seq,
486 };
487 let call = tools::AnalyzeSymbolCall {
488 ct,
489 param_path,
490 max_depth_val,
491 span,
492 t_start,
493 };
494 tools::analyze_symbol::analyze_symbol_handler(ctx, params, call).await
495 }
496
497 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
498 #[tool(
499 name = "analyze_module",
500 title = "Analyze Module",
501 description = "Lightweight function and import index for a single source file with minimal token cost: name, line_count, language, function names with line numbers, import list only (~75% smaller than analyze_file). Fails if directory path supplied. Pagination and git_ref not supported. Use analyze_file for signatures, types, or class details. Supported: Astro, C/C++, C#, CSS, Fortran, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, Python, Rust, TOML, TSX, TypeScript, YAML.",
502 output_schema = schema_for_type::<types::ModuleInfo>(),
503 annotations(
504 title = "Analyze Module",
505 read_only_hint = true,
506 destructive_hint = false,
507 idempotent_hint = true,
508 open_world_hint = false
509 )
510 )]
511 async fn analyze_module(
512 &self,
513 params: Parameters<AnalyzeModuleParams>,
514 context: RequestContext<RoleServer>,
515 ) -> Result<CallToolResult, ErrorData> {
516 let params = params.0;
517 let t_start = std::time::Instant::now();
518 let (seq, sid) = self.emit_received_metric("analyze_module").await;
519 let session_id = self.session_id.lock().await.clone();
520 let client_name = self.client_name.lock().await.clone();
521 let client_version = self.client_version.lock().await.clone();
522 extract_and_set_trace_context(
523 Some(&context.meta),
524 ClientMetadata {
525 session_id,
526 client_name,
527 client_version,
528 },
529 );
530 let span = tracing::Span::current();
531 span.record("gen_ai.system", "mcp");
532 span.record("gen_ai.operation.name", "execute_tool");
533 span.record("gen_ai.tool.name", "analyze_module");
534 span.record("path", ¶ms.path);
535 let _validated_path = match validate_path(¶ms.path, true) {
536 Ok(p) => p,
537 Err(e) => {
538 span.record("error", true);
539 span.record("error.type", "invalid_params");
540 return Ok(err_to_tool_result(e));
541 }
542 };
543 let param_path = params.path.clone();
544 let ctx = tools::AnalyzeModuleContext {
545 disk_cache: self.disk_cache.clone(),
546 metrics_tx: self.metrics_tx.clone(),
547 sid: sid.clone(),
548 seq,
549 };
550 tools::analyze_module::analyze_module_handler(ctx, params, param_path, &span, t_start).await
551 }
552
553 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
554 #[tool(
555 name = "edit_overwrite",
556 title = "Edit Overwrite",
557 description = "Creates or overwrites a file with UTF-8 content; creates parent directories if needed. Works on any file type. Use edit_replace for targeted single-block edits. working_dir sets the base directory for path resolution (default: server CWD).",
558 output_schema = schema_for_type::<EditOverwriteOutput>(),
559 annotations(
560 title = "Edit Overwrite",
561 read_only_hint = false,
562 destructive_hint = true,
563 idempotent_hint = false,
564 open_world_hint = false
565 )
566 )]
567 async fn edit_overwrite(
568 &self,
569 params: Parameters<EditOverwriteParams>,
570 context: RequestContext<RoleServer>,
571 ) -> Result<CallToolResult, ErrorData> {
572 let params = params.0;
573 let t_start = std::time::Instant::now();
574 let (seq, sid) = self.emit_received_metric("edit_overwrite").await;
575 let session_id = self.session_id.lock().await.clone();
577 let client_name = self.client_name.lock().await.clone();
578 let client_version = self.client_version.lock().await.clone();
579 extract_and_set_trace_context(
580 Some(&context.meta),
581 ClientMetadata {
582 session_id,
583 client_name,
584 client_version,
585 },
586 );
587 let span = tracing::Span::current();
588 span.record("gen_ai.system", "mcp");
589 span.record("gen_ai.operation.name", "execute_tool");
590 tools::edit_overwrite::edit_overwrite(
591 params,
592 tools::EditHandlerContext {
593 sid,
594 seq,
595 cache: &self.cache,
596 metrics_tx: &self.metrics_tx,
597 edit_failure_counts: &self.edit_failure_counts,
598 file_edit_locks: &self.file_edit_locks,
599 },
600 &span,
601 t_start,
602 )
603 .await
604 }
605
606 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
607 #[tool(
608 name = "edit_replace",
609 title = "Edit Replace",
610 description = "Replaces an exact text block; old_text must appear exactly once. Fails if zero or multiple matches (extend old_text to disambiguate). Set replace_all=true to replace every non-overlapping occurrence; old_text must be non-empty. Pass empty new_text to delete. CRLF in old_text normalized to LF; all other whitespace matched exactly. Pass expected_content_hash (blake3 hex of raw file bytes) to detect stale context; a mismatch rejects the edit. On invalid_params, re-read with analyze_file or analyze_module and retry. Use edit_overwrite to replace the whole file. working_dir sets the base directory for path resolution (default: server CWD).",
611 output_schema = schema_for_type::<EditReplaceOutput>(),
612 annotations(
613 title = "Edit Replace",
614 read_only_hint = false,
615 destructive_hint = true,
616 idempotent_hint = false,
617 open_world_hint = false
618 )
619 )]
620 async fn edit_replace(
621 &self,
622 params: Parameters<EditReplaceParams>,
623 context: RequestContext<RoleServer>,
624 ) -> Result<CallToolResult, ErrorData> {
625 let params = params.0;
626 let t_start = std::time::Instant::now();
627 let (seq, sid) = self.emit_received_metric("edit_replace").await;
628 let session_id = self.session_id.lock().await.clone();
630 let client_name = self.client_name.lock().await.clone();
631 let client_version = self.client_version.lock().await.clone();
632 extract_and_set_trace_context(
633 Some(&context.meta),
634 ClientMetadata {
635 session_id,
636 client_name,
637 client_version,
638 },
639 );
640 let span = tracing::Span::current();
641 span.record("gen_ai.system", "mcp");
642 span.record("gen_ai.operation.name", "execute_tool");
643 tools::edit_replace::edit_replace(
644 params,
645 tools::EditHandlerContext {
646 sid,
647 seq,
648 cache: &self.cache,
649 metrics_tx: &self.metrics_tx,
650 edit_failure_counts: &self.edit_failure_counts,
651 file_edit_locks: &self.file_edit_locks,
652 },
653 &span,
654 t_start,
655 )
656 .await
657 }
658
659 #[tool(
660 name = "exec_command",
661 title = "Exec Command",
662 description = "Execute shell command via sh -c (or $SHELL if set). Output capped at 30 KB stdout / 10 KB stderr / 2000 lines. Set working_dir to the target directory; write commands with relative paths only. Pass stdin to pipe UTF-8 content (max 1 MB); heredoc syntax is rejected. For file writes use edit_overwrite or edit_replace. Prefer machine-readable output flags (e.g. --json) to reduce tokens.",
663 output_schema = schema_for_type::<ShellOutput>(),
664 annotations(
665 title = "Exec Command",
666 read_only_hint = false,
667 destructive_hint = true,
668 idempotent_hint = false,
669 open_world_hint = true
670 )
671 )]
672 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, command = tracing::field::Empty, exit_code = tracing::field::Empty, output_truncated = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
673 pub async fn exec_command(
674 &self,
675 params: Parameters<ExecCommandParams>,
676 context: RequestContext<RoleServer>,
677 ) -> Result<CallToolResult, ErrorData> {
678 let t_start = std::time::Instant::now();
679 let (seq, sid) = self.emit_received_metric("exec_command").await;
680 let params = params.0;
681 let session_id = self.session_id.lock().await.clone();
682 let client_name = self.client_name.lock().await.clone();
683 let client_version = self.client_version.lock().await.clone();
684 let ctx = crate::tools::exec_command::ExecContext {
685 seq,
686 sid,
687 session_id,
688 client_name,
689 client_version,
690 resolved_path: self.resolved_path.as_ref().as_deref().map(str::to_owned),
691 filter_table: self.filter_table.clone(),
692 metrics_tx: self.metrics_tx.clone(),
693 t_start,
694 };
695 crate::tools::exec_command::exec_command_impl(params, context, ctx).await
696 }
697}
698
699#[tool_handler]
700impl ServerHandler for CodeAnalyzer {
701 #[instrument(skip(self, _context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
702 async fn initialize(
703 &self,
704 request: InitializeRequestParams,
705 _context: RequestContext<RoleServer>,
706 ) -> Result<InitializeResult, ErrorData> {
707 let span = tracing::Span::current();
708 span.record("service.name", "aptu-coder");
709 span.record("service.version", env!("CARGO_PKG_VERSION"));
710
711 {
713 let mut client_name_lock = self.client_name.lock().await;
714 *client_name_lock = Some(request.client_info.name.clone());
715 }
716 {
717 let mut client_version_lock = self.client_version.lock().await;
718 *client_version_lock = Some(request.client_info.version.clone());
719 }
720 Ok(self.get_info())
721 }
722
723 async fn discover(
726 &self,
727 _context: RequestContext<RoleServer>,
728 ) -> Result<DiscoverResult, ErrorData> {
729 Ok(DiscoverResult::from_server_info(
730 self.supported_protocol_versions().into_owned(),
731 self.get_info(),
732 ))
733 }
734
735 fn get_info(&self) -> InitializeResult {
736 let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
737 let instructions = format!(
738 "Recommended workflow:\n\
739 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
740 2. Re-run analyze_directory(path=<source_package>, max_depth=2, summary=true) for module map. Include test directories (tests/, *_test.go, test_*.py, test_*.rs, *.spec.ts, *.spec.js).\n\
741 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
742 4. Use analyze_symbol to trace call graphs.\n\
743 Prefer summary=true on 1000+ files. Set max_depth=2; increase if packages too large. Paginate with cursor/page_size. For subagents: DISABLE_PROMPT_CACHING=1.\n\
744 JSONL metrics at $HOME/.local/share/aptu-coder/ (or $XDG_DATA_HOME/aptu-coder/). Always cd there before jq glob queries."
745 );
746 let capabilities = ServerCapabilities::builder()
747 .enable_tools()
748 .enable_tool_list_changed()
749 .enable_completions()
750 .enable_resources()
751 .build();
752 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
753 .with_title("Aptu Coder")
754 .with_description("MCP server for code structure analysis using tree-sitter");
755 InitializeResult::new(capabilities)
756 .with_server_info(server_info)
757 .with_instructions(&instructions)
758 }
759
760 async fn list_tools(
761 &self,
762 _request: Option<rmcp::model::PaginatedRequestParams>,
763 _context: RequestContext<RoleServer>,
764 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
765 let router = self.tool_router.read().await;
766 Ok(
767 rmcp::model::ListToolsResult::with_all_items(router.list_all())
768 .with_ttl_ms(3_600_000)
769 .with_cache_scope(CacheScope::Public),
770 )
771 }
772
773 async fn call_tool(
774 &self,
775 request: rmcp::model::CallToolRequestParams,
776 context: RequestContext<RoleServer>,
777 ) -> Result<CallToolResponse, ErrorData> {
778 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
779 let router = self.tool_router.read().await;
780 router.call(tcc).await
781 }
782
783 async fn list_resources(
784 &self,
785 _request: Option<rmcp::model::PaginatedRequestParams>,
786 _context: RequestContext<RoleServer>,
787 ) -> Result<ListResourcesResult, ErrorData> {
788 tools::resources::list_resources_impl(_request, &_context)
789 }
790
791 async fn list_resource_templates(
792 &self,
793 _request: Option<rmcp::model::PaginatedRequestParams>,
794 _context: RequestContext<RoleServer>,
795 ) -> Result<ListResourceTemplatesResult, ErrorData> {
796 tools::resources::list_resource_templates_impl(_request, &_context)
797 }
798
799 async fn read_resource(
800 &self,
801 request: ReadResourceRequestParams,
802 _context: RequestContext<RoleServer>,
803 ) -> Result<ReadResourceResponse, ErrorData> {
804 tools::resources::read_resource_impl(request, &self.graph_store)
805 }
806
807 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
808 crate::tools::server::on_initialized_impl(
809 self.peer.clone(),
810 self.session_id.clone(),
811 self.session_call_seq.clone(),
812 self.tool_router.clone(),
813 &context.peer,
814 )
815 .await;
816 }
817
818 #[instrument(skip(self, _context))]
819 async fn on_cancelled(
820 &self,
821 notification: CancelledNotificationParam,
822 _context: NotificationContext<RoleServer>,
823 ) {
824 tracing::info!(
825 request_id = ?notification.request_id,
826 reason = ?notification.reason,
827 "Received cancellation notification"
828 );
829 }
830
831 #[instrument(skip(self, _context))]
832 async fn complete(
833 &self,
834 request: CompleteRequestParams,
835 _context: RequestContext<RoleServer>,
836 ) -> Result<CompleteResult, ErrorData> {
837 let argument_name = &request.argument.name;
839 let argument_value = &request.argument.value;
840
841 let completions = match argument_name.as_str() {
842 "path" => {
843 let root = Path::new(".");
845 completion::path_completions(root, argument_value)
846 }
847 "symbol" => {
848 let path_arg = request
850 .context
851 .as_ref()
852 .and_then(|ctx| ctx.get_argument("path"));
853
854 match path_arg {
855 Some(path_str) => {
856 let path = Path::new(path_str);
857 completion::symbol_completions(&self.cache, path, argument_value)
858 }
859 None => Vec::new(),
860 }
861 }
862 _ => Vec::new(),
863 };
864
865 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
867 let (values, has_more) = if completions.len() > 100 {
868 (completions.into_iter().take(100).collect(), true)
869 } else {
870 (completions, false)
871 };
872
873 let completion_info =
874 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
875 Ok(info) => info,
876 Err(_) => {
877 CompletionInfo::with_all_values(Vec::new())
879 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
880 }
881 };
882
883 Ok(CompleteResult::new(completion_info))
884 }
885}
886
887#[cfg(test)]
888mod tests;