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