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