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};
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 edit_failure_counts: Arc<Mutex<HashMap<(String, String), u8>>>,
224 file_edit_locks: tools::FileEditLockRegistry,
228 graph_store: std::sync::Arc<aptu_coder_core::graph::GraphDiskStore>,
230}
231
232#[tool_router]
233impl CodeAnalyzer {
234 #[must_use]
235 pub fn list_tools() -> Vec<rmcp::model::Tool> {
236 Self::tool_router().list_all()
237 }
238
239 pub fn new(
240 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
241 metrics_tx: crate::metrics::MetricsSender,
242 ) -> Self {
243 crate::tools::server::build_analyzer(peer, metrics_tx)
244 }
245
246 async fn emit_received_metric(&self, tool: &'static str) -> (u32, Option<String>) {
251 crate::tools::server::emit_received_metric(
252 &self.metrics_tx,
253 &self.session_id,
254 &self.session_call_seq,
255 tool,
256 )
257 .await
258 }
259
260 #[cfg(test)]
263 pub(crate) async fn handle_overview_mode(
264 &self,
265 params: &AnalyzeDirectoryParams,
266 ct: tokio_util::sync::CancellationToken,
267 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
268 let ctx = crate::tools::AnalyzeDirectoryContext {
269 cache: self.cache.clone(),
270 disk_cache: self.disk_cache.clone(),
271 metrics_tx: self.metrics_tx.clone(),
272 peer: self.peer.clone(),
273 sid: self.session_id.lock().await.clone(),
274 };
275 crate::tools::server::handle_overview_mode(&ctx, params, ct).await
276 }
277
278 #[cfg(test)]
281 pub(crate) async fn handle_file_details_mode(
282 &self,
283 params: &aptu_coder_core::types::AnalyzeFileParams,
284 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
285 crate::tools::server::handle_file_details_mode(
286 self.cache.clone(),
287 self.disk_cache.clone(),
288 self.metrics_tx.clone(),
289 self.session_id.lock().await.clone(),
290 params,
291 )
292 .await
293 }
294
295 #[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))]
296 #[tool(
297 name = "analyze_directory",
298 title = "Analyze Directory",
299 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.",
300 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
301 annotations(
302 title = "Analyze Directory",
303 read_only_hint = true,
304 destructive_hint = false,
305 idempotent_hint = true,
306 open_world_hint = false
307 )
308 )]
309 async fn analyze_directory(
310 &self,
311 params: Parameters<AnalyzeDirectoryParams>,
312 context: RequestContext<RoleServer>,
313 ) -> Result<CallToolResult, ErrorData> {
314 let mut params = params.0;
315 params.max_depth = params.max_depth.or(Some(3));
316 let t_start = std::time::Instant::now();
317 let (seq, sid) = self.emit_received_metric("analyze_directory").await;
318 let session_id = self.session_id.lock().await.clone();
319 let client_name = self.client_name.lock().await.clone();
320 let client_version = self.client_version.lock().await.clone();
321 extract_and_set_trace_context(
322 Some(&context.meta),
323 ClientMetadata {
324 session_id,
325 client_name,
326 client_version,
327 },
328 );
329 let span = tracing::Span::current();
330 span.record("gen_ai.system", "mcp");
331 span.record("gen_ai.operation.name", "execute_tool");
332 span.record("gen_ai.tool.name", "analyze_directory");
333 span.record("path", ¶ms.path);
334 let _validated_path = match validate_path(¶ms.path, true) {
335 Ok(p) => p,
336 Err(e) => {
337 span.record("error", true);
338 span.record("error.type", "invalid_params");
339 return Ok(err_to_tool_result(e));
340 }
341 };
342 let ct = context.ct.clone();
343 let param_path = params.path.clone();
344 let max_depth_val = params.max_depth;
345 let ctx = tools::AnalyzeDirectoryContext {
346 cache: self.cache.clone(),
347 disk_cache: self.disk_cache.clone(),
348 metrics_tx: self.metrics_tx.clone(),
349 peer: self.peer.clone(),
350 sid: sid.clone(),
351 };
352 tools::analyze_directory::analyze_directory_handler(
353 &ctx,
354 params,
355 tools::DirectoryHandlerCall {
356 seq,
357 sid,
358 t_start,
359 param_path,
360 max_depth_val,
361 ct,
362 },
363 &span,
364 )
365 .await
366 }
367
368 #[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))]
369 #[tool(
370 name = "analyze_file",
371 title = "Analyze File",
372 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.",
373 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
374 annotations(
375 title = "Analyze File",
376 read_only_hint = true,
377 destructive_hint = false,
378 idempotent_hint = true,
379 open_world_hint = false
380 )
381 )]
382 async fn analyze_file(
383 &self,
384 params: Parameters<AnalyzeFileParams>,
385 context: RequestContext<RoleServer>,
386 ) -> Result<CallToolResult, ErrorData> {
387 let params = params.0;
388 let t_start = std::time::Instant::now();
389 let (seq, sid) = self.emit_received_metric("analyze_file").await;
390 let session_id = self.session_id.lock().await.clone();
391 let client_name = self.client_name.lock().await.clone();
392 let client_version = self.client_version.lock().await.clone();
393 extract_and_set_trace_context(
394 Some(&context.meta),
395 ClientMetadata {
396 session_id,
397 client_name,
398 client_version,
399 },
400 );
401 let span = tracing::Span::current();
402 span.record("gen_ai.system", "mcp");
403 span.record("gen_ai.operation.name", "execute_tool");
404 span.record("gen_ai.tool.name", "analyze_file");
405 span.record("path", ¶ms.path);
406 let _validated_path = match validate_path(¶ms.path, true) {
407 Ok(p) => p,
408 Err(e) => {
409 span.record("error", true);
410 span.record("error.type", "invalid_params");
411 return Ok(err_to_tool_result(e));
412 }
413 };
414 let param_path = params.path.clone();
415 let ctx = tools::AnalyzeFileContext {
416 cache: self.cache.clone(),
417 disk_cache: self.disk_cache.clone(),
418 metrics_tx: self.metrics_tx.clone(),
419 sid: sid.clone(),
420 };
421 tools::analyze_file::analyze_file_handler(
422 &ctx, params, seq, sid, t_start, param_path, &span,
423 )
424 .await
425 }
426
427 #[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))]
428 #[tool(
429 name = "analyze_symbol",
430 title = "Analyze Symbol",
431 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.",
432 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
433 annotations(
434 title = "Analyze Symbol",
435 read_only_hint = true,
436 destructive_hint = false,
437 idempotent_hint = true,
438 open_world_hint = false
439 )
440 )]
441 async fn analyze_symbol(
442 &self,
443 params: Parameters<AnalyzeSymbolParams>,
444 context: RequestContext<RoleServer>,
445 ) -> Result<CallToolResult, ErrorData> {
446 let params = params.0;
447 let t_start = std::time::Instant::now();
448 let (seq, sid) = self.emit_received_metric("analyze_symbol").await;
449 let session_id = self.session_id.lock().await.clone();
450 let client_name = self.client_name.lock().await.clone();
451 let client_version = self.client_version.lock().await.clone();
452 extract_and_set_trace_context(
453 Some(&context.meta),
454 ClientMetadata {
455 session_id,
456 client_name,
457 client_version,
458 },
459 );
460 let span = tracing::Span::current();
461 span.record("gen_ai.system", "mcp");
462 span.record("gen_ai.operation.name", "execute_tool");
463 span.record("gen_ai.tool.name", "analyze_symbol");
464 span.record("symbol", ¶ms.symbol);
465 let _validated_path = match validate_path(¶ms.path, true) {
466 Ok(p) => p,
467 Err(e) => {
468 span.record("error", true);
469 span.record("error.type", "invalid_params");
470 return Ok(err_to_tool_result(e));
471 }
472 };
473 let ct = context.ct.clone();
474 let param_path = params.path.clone();
475 let max_depth_val = params.follow_depth;
476 let ctx = tools::AnalyzeSymbolContext {
477 metrics_tx: self.metrics_tx.clone(),
478 call_graph_cache: self.call_graph_cache.clone(),
479 disk_cache: self.disk_cache.clone(),
480 sid: sid.clone(),
481 seq,
482 };
483 let call = tools::AnalyzeSymbolCall {
484 ct,
485 param_path,
486 max_depth_val,
487 span,
488 t_start,
489 };
490 tools::analyze_symbol::analyze_symbol_handler(ctx, params, call).await
491 }
492
493 #[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))]
494 #[tool(
495 name = "analyze_module",
496 title = "Analyze Module",
497 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.",
498 output_schema = schema_for_type::<types::ModuleInfo>(),
499 annotations(
500 title = "Analyze Module",
501 read_only_hint = true,
502 destructive_hint = false,
503 idempotent_hint = true,
504 open_world_hint = false
505 )
506 )]
507 async fn analyze_module(
508 &self,
509 params: Parameters<AnalyzeModuleParams>,
510 context: RequestContext<RoleServer>,
511 ) -> Result<CallToolResult, ErrorData> {
512 let params = params.0;
513 let t_start = std::time::Instant::now();
514 let (seq, sid) = self.emit_received_metric("analyze_module").await;
515 let session_id = self.session_id.lock().await.clone();
516 let client_name = self.client_name.lock().await.clone();
517 let client_version = self.client_version.lock().await.clone();
518 extract_and_set_trace_context(
519 Some(&context.meta),
520 ClientMetadata {
521 session_id,
522 client_name,
523 client_version,
524 },
525 );
526 let span = tracing::Span::current();
527 span.record("gen_ai.system", "mcp");
528 span.record("gen_ai.operation.name", "execute_tool");
529 span.record("gen_ai.tool.name", "analyze_module");
530 span.record("path", ¶ms.path);
531 let _validated_path = match validate_path(¶ms.path, true) {
532 Ok(p) => p,
533 Err(e) => {
534 span.record("error", true);
535 span.record("error.type", "invalid_params");
536 return Ok(err_to_tool_result(e));
537 }
538 };
539 let param_path = params.path.clone();
540 let ctx = tools::AnalyzeModuleContext {
541 disk_cache: self.disk_cache.clone(),
542 metrics_tx: self.metrics_tx.clone(),
543 sid: sid.clone(),
544 seq,
545 };
546 tools::analyze_module::analyze_module_handler(ctx, params, param_path, &span, t_start).await
547 }
548
549 #[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))]
550 #[tool(
551 name = "edit_overwrite",
552 title = "Edit Overwrite",
553 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).",
554 output_schema = schema_for_type::<EditOverwriteOutput>(),
555 annotations(
556 title = "Edit Overwrite",
557 read_only_hint = false,
558 destructive_hint = true,
559 idempotent_hint = false,
560 open_world_hint = false
561 )
562 )]
563 async fn edit_overwrite(
564 &self,
565 params: Parameters<EditOverwriteParams>,
566 context: RequestContext<RoleServer>,
567 ) -> Result<CallToolResult, ErrorData> {
568 let params = params.0;
569 let t_start = std::time::Instant::now();
570 let (seq, sid) = self.emit_received_metric("edit_overwrite").await;
571 let session_id = self.session_id.lock().await.clone();
573 let client_name = self.client_name.lock().await.clone();
574 let client_version = self.client_version.lock().await.clone();
575 extract_and_set_trace_context(
576 Some(&context.meta),
577 ClientMetadata {
578 session_id,
579 client_name,
580 client_version,
581 },
582 );
583 let span = tracing::Span::current();
584 span.record("gen_ai.system", "mcp");
585 span.record("gen_ai.operation.name", "execute_tool");
586 tools::edit_overwrite::edit_overwrite(
587 params,
588 tools::EditHandlerContext {
589 sid,
590 seq,
591 cache: &self.cache,
592 metrics_tx: &self.metrics_tx,
593 edit_failure_counts: &self.edit_failure_counts,
594 file_edit_locks: &self.file_edit_locks,
595 },
596 &span,
597 t_start,
598 )
599 .await
600 }
601
602 #[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))]
603 #[tool(
604 name = "edit_replace",
605 title = "Edit Replace",
606 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).",
607 output_schema = schema_for_type::<EditReplaceOutput>(),
608 annotations(
609 title = "Edit Replace",
610 read_only_hint = false,
611 destructive_hint = true,
612 idempotent_hint = false,
613 open_world_hint = false
614 )
615 )]
616 async fn edit_replace(
617 &self,
618 params: Parameters<EditReplaceParams>,
619 context: RequestContext<RoleServer>,
620 ) -> Result<CallToolResult, ErrorData> {
621 let params = params.0;
622 let t_start = std::time::Instant::now();
623 let (seq, sid) = self.emit_received_metric("edit_replace").await;
624 let session_id = self.session_id.lock().await.clone();
626 let client_name = self.client_name.lock().await.clone();
627 let client_version = self.client_version.lock().await.clone();
628 extract_and_set_trace_context(
629 Some(&context.meta),
630 ClientMetadata {
631 session_id,
632 client_name,
633 client_version,
634 },
635 );
636 let span = tracing::Span::current();
637 span.record("gen_ai.system", "mcp");
638 span.record("gen_ai.operation.name", "execute_tool");
639 tools::edit_replace::edit_replace(
640 params,
641 tools::EditHandlerContext {
642 sid,
643 seq,
644 cache: &self.cache,
645 metrics_tx: &self.metrics_tx,
646 edit_failure_counts: &self.edit_failure_counts,
647 file_edit_locks: &self.file_edit_locks,
648 },
649 &span,
650 t_start,
651 )
652 .await
653 }
654
655 #[tool(
656 name = "exec_command",
657 title = "Exec Command",
658 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.",
659 output_schema = schema_for_type::<ShellOutput>(),
660 annotations(
661 title = "Exec Command",
662 read_only_hint = false,
663 destructive_hint = true,
664 idempotent_hint = false,
665 open_world_hint = true
666 )
667 )]
668 #[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))]
669 pub async fn exec_command(
670 &self,
671 params: Parameters<ExecCommandParams>,
672 context: RequestContext<RoleServer>,
673 ) -> Result<CallToolResult, ErrorData> {
674 let t_start = std::time::Instant::now();
675 let (seq, sid) = self.emit_received_metric("exec_command").await;
676 let params = params.0;
677 let session_id = self.session_id.lock().await.clone();
678 let client_name = self.client_name.lock().await.clone();
679 let client_version = self.client_version.lock().await.clone();
680 let ctx = crate::tools::exec_command::ExecContext {
681 seq,
682 sid,
683 session_id,
684 client_name,
685 client_version,
686 resolved_path: self.resolved_path.as_ref().as_deref().map(str::to_owned),
687 filter_table: self.filter_table.clone(),
688 metrics_tx: self.metrics_tx.clone(),
689 t_start,
690 };
691 crate::tools::exec_command::exec_command_impl(params, context, ctx).await
692 }
693}
694
695#[tool_handler]
696impl ServerHandler for CodeAnalyzer {
697 #[instrument(skip(self, _context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
698 async fn initialize(
699 &self,
700 request: InitializeRequestParams,
701 _context: RequestContext<RoleServer>,
702 ) -> Result<InitializeResult, ErrorData> {
703 let span = tracing::Span::current();
704 span.record("service.name", "aptu-coder");
705 span.record("service.version", env!("CARGO_PKG_VERSION"));
706
707 {
709 let mut client_name_lock = self.client_name.lock().await;
710 *client_name_lock = Some(request.client_info.name.clone());
711 }
712 {
713 let mut client_version_lock = self.client_version.lock().await;
714 *client_version_lock = Some(request.client_info.version.clone());
715 }
716 Ok(self.get_info())
717 }
718
719 async fn discover(
722 &self,
723 _context: RequestContext<RoleServer>,
724 ) -> Result<DiscoverResult, ErrorData> {
725 Ok(DiscoverResult::from_server_info(
726 self.supported_protocol_versions().into_owned(),
727 self.get_info(),
728 ))
729 }
730
731 fn get_info(&self) -> InitializeResult {
732 let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
733 let instructions = format!(
734 "Recommended workflow:\n\
735 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
736 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\
737 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
738 4. Use analyze_symbol to trace call graphs.\n\
739 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\
740 JSONL metrics at $HOME/.local/share/aptu-coder/ (or $XDG_DATA_HOME/aptu-coder/). Always cd there before jq glob queries."
741 );
742 let capabilities = ServerCapabilities::builder()
743 .enable_tools()
744 .enable_tool_list_changed()
745 .enable_completions()
746 .enable_resources()
747 .build();
748 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
749 .with_title("Aptu Coder")
750 .with_description("MCP server for code structure analysis using tree-sitter");
751 InitializeResult::new(capabilities)
752 .with_server_info(server_info)
753 .with_instructions(&instructions)
754 }
755
756 async fn list_tools(
757 &self,
758 _request: Option<rmcp::model::PaginatedRequestParams>,
759 _context: RequestContext<RoleServer>,
760 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
761 let router = self.tool_router.read().await;
762 Ok(
763 rmcp::model::ListToolsResult::with_all_items(router.list_all())
764 .with_ttl_ms(3_600_000)
765 .with_cache_scope(CacheScope::Public),
766 )
767 }
768
769 async fn call_tool(
770 &self,
771 request: rmcp::model::CallToolRequestParams,
772 context: RequestContext<RoleServer>,
773 ) -> Result<CallToolResponse, ErrorData> {
774 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
775 let router = self.tool_router.read().await;
776 router.call(tcc).await
777 }
778
779 async fn list_resources(
780 &self,
781 _request: Option<rmcp::model::PaginatedRequestParams>,
782 _context: RequestContext<RoleServer>,
783 ) -> Result<ListResourcesResult, ErrorData> {
784 tools::resources::list_resources_impl(_request, &_context)
785 }
786
787 async fn list_resource_templates(
788 &self,
789 _request: Option<rmcp::model::PaginatedRequestParams>,
790 _context: RequestContext<RoleServer>,
791 ) -> Result<ListResourceTemplatesResult, ErrorData> {
792 tools::resources::list_resource_templates_impl(_request, &_context)
793 }
794
795 async fn read_resource(
796 &self,
797 request: ReadResourceRequestParams,
798 _context: RequestContext<RoleServer>,
799 ) -> Result<ReadResourceResponse, ErrorData> {
800 tools::resources::read_resource_impl(request, &self.graph_store)
801 }
802
803 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
804 crate::tools::server::on_initialized_impl(
805 self.peer.clone(),
806 self.session_id.clone(),
807 self.session_call_seq.clone(),
808 self.tool_router.clone(),
809 &context.peer,
810 )
811 .await;
812 }
813
814 #[instrument(skip(self, _context))]
815 async fn on_cancelled(
816 &self,
817 notification: CancelledNotificationParam,
818 _context: NotificationContext<RoleServer>,
819 ) {
820 tracing::info!(
821 request_id = ?notification.request_id,
822 reason = ?notification.reason,
823 "Received cancellation notification"
824 );
825 }
826
827 #[instrument(skip(self, _context))]
828 async fn complete(
829 &self,
830 request: CompleteRequestParams,
831 _context: RequestContext<RoleServer>,
832 ) -> Result<CompleteResult, ErrorData> {
833 let argument_name = &request.argument.name;
835 let argument_value = &request.argument.value;
836
837 let completions = match argument_name.as_str() {
838 "path" => {
839 let root = Path::new(".");
841 completion::path_completions(root, argument_value)
842 }
843 "symbol" => {
844 let path_arg = request
846 .context
847 .as_ref()
848 .and_then(|ctx| ctx.get_argument("path"));
849
850 match path_arg {
851 Some(path_str) => {
852 let path = Path::new(path_str);
853 completion::symbol_completions(&self.cache, path, argument_value)
854 }
855 None => Vec::new(),
856 }
857 }
858 _ => Vec::new(),
859 };
860
861 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
863 let (values, has_more) = if completions.len() > 100 {
864 (completions.into_iter().take(100).collect(), true)
865 } else {
866 (completions, false)
867 };
868
869 let completion_info =
870 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
871 Ok(info) => info,
872 Err(_) => {
873 CompletionInfo::with_all_values(Vec::new())
875 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
876 }
877 };
878
879 Ok(CompleteResult::new(completion_info))
880 }
881}
882
883#[cfg(test)]
884mod tests;