1#![cfg_attr(test, allow(clippy::unwrap_used))]
27
28mod filters;
29pub(crate) mod logging;
30pub(crate) mod metrics;
31pub(crate) mod otel;
32pub(crate) mod shell;
33pub(crate) mod shell_write;
35pub(crate) mod tools;
36pub(crate) mod validation;
37
38pub use logging::{LogEvent, McpLoggingLayer};
39pub use metrics::{MetricEvent, MetricsSender, MetricsWriter, migrate_legacy_metrics_dir};
40pub use otel::{
41 ClientMetadata, extract_and_set_trace_context, init_log_appender, init_meter, init_otel,
42};
43
44use aptu_coder_core::analyze;
45use aptu_coder_core::{cache, completion, types};
46use validation::validate_path;
47
48use crate::tools::common::{err_to_tool_result, no_cache_meta};
49
50pub const STDIN_MAX_BYTES: usize = 1_048_576;
51
52#[non_exhaustive]
53#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
54pub struct ExecCommandParams {
55 pub command: String,
57 pub working_dir: Option<String>,
59 pub stdin: Option<String>,
61 #[serde(default)]
65 pub timeout_secs: Option<i64>,
66 #[serde(default)]
73 pub drain_timeout_secs: Option<i64>,
74}
75
76impl ExecCommandParams {
77 #[must_use]
79 pub fn new(command: String, working_dir: Option<String>) -> Self {
80 Self {
81 command,
82 working_dir,
83 ..Default::default()
84 }
85 }
86}
87
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
89pub struct ShellOutput {
90 pub stdout: String,
92 pub stderr: String,
94 pub interleaved: String,
96 pub exit_code: Option<i32>,
98 pub output_truncated: bool,
102 pub output_collection_error: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub stdout_path: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub stderr_path: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub filter_applied: Option<String>,
115 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
118 pub timed_out: bool,
119}
120
121impl ShellOutput {
122 #[must_use]
124 pub fn new(
125 stdout: String,
126 stderr: String,
127 interleaved: String,
128 exit_code: Option<i32>,
129 output_truncated: bool,
130 ) -> Self {
131 Self {
132 stdout,
133 stderr,
134 interleaved,
135 exit_code,
136 output_truncated,
137 output_collection_error: None,
138 stdout_path: None,
139 stderr_path: None,
140 filter_applied: None,
141 timed_out: false,
142 }
143 }
144}
145
146#[cfg(test)]
147use aptu_coder_core::cache::CacheTier;
148use aptu_coder_core::cache::{AnalysisCache, CallGraphCache};
149use aptu_coder_core::types::{
150 AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams, AnalyzeSymbolParams,
151 EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput, EditReplaceParams,
152};
153use filters::CompiledRule;
154#[cfg(test)]
155use filters::{apply_filter, maybe_inject_no_stat};
156
157use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
158use rmcp::handler::server::wrapper::Parameters;
159use rmcp::model::{
160 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
161 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
162 LoggingLevel, Meta, ServerCapabilities, SetLevelRequestParams,
163};
164use rmcp::service::{NotificationContext, RequestContext};
165use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
166
167use std::collections::HashMap;
168use std::path::Path;
169use std::sync::{Arc, Mutex};
170use tokio::sync::{Mutex as TokioMutex, RwLock};
171use tracing::instrument;
172use tracing_subscriber::filter::LevelFilter;
173
174static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
175
176pub(crate) const SIZE_LIMIT: usize = 5_000;
181
182pub(crate) fn err_to_tool_result_from_pagination(
183 e: aptu_coder_core::pagination::PaginationError,
184) -> CallToolResult {
185 let msg = format!("Pagination error: {}", e);
186 CallToolResult::error(vec![Content::text(msg)]).with_meta(Some(no_cache_meta()))
187}
188
189#[derive(Clone)]
194pub struct CodeAnalyzer {
195 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 log_level_filter: Arc<Mutex<LevelFilter>>,
207 metrics_tx: crate::metrics::MetricsSender,
208 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
209 session_id: Arc<TokioMutex<Option<String>>>,
210 session_profile: Arc<std::sync::OnceLock<String>>,
213 client_name: Arc<TokioMutex<Option<String>>>,
214 client_version: Arc<TokioMutex<Option<String>>>,
215 resolved_path: Arc<Option<String>>,
218 filter_table: Arc<Vec<CompiledRule>>,
221 call_graph_cache: CallGraphCache,
224 edit_failure_counts: Arc<Mutex<HashMap<(String, String), u8>>>,
228}
229
230#[tool_router]
231impl CodeAnalyzer {
232 #[must_use]
233 pub fn list_tools() -> Vec<rmcp::model::Tool> {
234 Self::tool_router().list_all()
235 }
236
237 pub fn new(
238 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
239 log_level_filter: Arc<Mutex<LevelFilter>>,
240 metrics_tx: crate::metrics::MetricsSender,
241 ) -> Self {
242 crate::tools::server::build_analyzer(peer, log_level_filter, metrics_tx)
243 }
244
245 async fn emit_received_metric(&self, tool: &'static str) -> (u32, Option<String>) {
250 crate::tools::server::emit_received_metric(
251 &self.metrics_tx,
252 &self.session_id,
253 &self.session_call_seq,
254 tool,
255 )
256 .await
257 }
258
259 #[cfg(test)]
262 pub(crate) async fn handle_overview_mode(
263 &self,
264 params: &AnalyzeDirectoryParams,
265 ct: tokio_util::sync::CancellationToken,
266 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
267 let ctx = crate::tools::AnalyzeDirectoryContext {
268 cache: self.cache.clone(),
269 disk_cache: self.disk_cache.clone(),
270 metrics_tx: self.metrics_tx.clone(),
271 peer: self.peer.clone(),
272 sid: self.session_id.lock().await.clone(),
273 };
274 crate::tools::server::handle_overview_mode(&ctx, params, ct).await
275 }
276
277 #[cfg(test)]
280 pub(crate) async fn handle_file_details_mode(
281 &self,
282 params: &aptu_coder_core::types::AnalyzeFileParams,
283 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
284 crate::tools::server::handle_file_details_mode(
285 self.cache.clone(),
286 self.disk_cache.clone(),
287 self.metrics_tx.clone(),
288 self.session_id.lock().await.clone(),
289 params,
290 )
291 .await
292 }
293
294 #[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))]
295 #[tool(
296 name = "analyze_directory",
297 title = "Analyze Directory",
298 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.",
299 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
300 annotations(
301 title = "Analyze Directory",
302 read_only_hint = true,
303 destructive_hint = false,
304 idempotent_hint = true,
305 open_world_hint = false
306 )
307 )]
308 async fn analyze_directory(
309 &self,
310 params: Parameters<AnalyzeDirectoryParams>,
311 context: RequestContext<RoleServer>,
312 ) -> Result<CallToolResult, ErrorData> {
313 let mut params = params.0;
314 params.max_depth = params.max_depth.or(Some(3));
315 let t_start = std::time::Instant::now();
316 let (seq, sid) = self.emit_received_metric("analyze_directory").await;
317 let session_id = self.session_id.lock().await.clone();
318 let client_name = self.client_name.lock().await.clone();
319 let client_version = self.client_version.lock().await.clone();
320 extract_and_set_trace_context(
321 Some(&context.meta),
322 ClientMetadata {
323 session_id,
324 client_name,
325 client_version,
326 },
327 );
328 let span = tracing::Span::current();
329 span.record("gen_ai.system", "mcp");
330 span.record("gen_ai.operation.name", "execute_tool");
331 span.record("gen_ai.tool.name", "analyze_directory");
332 span.record("path", ¶ms.path);
333 let _validated_path = match validate_path(¶ms.path, true) {
334 Ok(p) => p,
335 Err(e) => {
336 span.record("error", true);
337 span.record("error.type", "invalid_params");
338 return Ok(err_to_tool_result(e));
339 }
340 };
341 let ct = context.ct.clone();
342 let param_path = params.path.clone();
343 let max_depth_val = params.max_depth;
344 let ctx = tools::AnalyzeDirectoryContext {
345 cache: self.cache.clone(),
346 disk_cache: self.disk_cache.clone(),
347 metrics_tx: self.metrics_tx.clone(),
348 peer: self.peer.clone(),
349 sid: sid.clone(),
350 };
351 tools::analyze_directory::analyze_directory_handler(
352 &ctx,
353 params,
354 tools::DirectoryHandlerCall {
355 seq,
356 sid,
357 t_start,
358 param_path,
359 max_depth_val,
360 ct,
361 },
362 &span,
363 )
364 .await
365 }
366
367 #[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))]
368 #[tool(
369 name = "analyze_file",
370 title = "Analyze File",
371 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.",
372 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
373 annotations(
374 title = "Analyze File",
375 read_only_hint = true,
376 destructive_hint = false,
377 idempotent_hint = true,
378 open_world_hint = false
379 )
380 )]
381 async fn analyze_file(
382 &self,
383 params: Parameters<AnalyzeFileParams>,
384 context: RequestContext<RoleServer>,
385 ) -> Result<CallToolResult, ErrorData> {
386 let params = params.0;
387 let t_start = std::time::Instant::now();
388 let (seq, sid) = self.emit_received_metric("analyze_file").await;
389 let session_id = self.session_id.lock().await.clone();
390 let client_name = self.client_name.lock().await.clone();
391 let client_version = self.client_version.lock().await.clone();
392 extract_and_set_trace_context(
393 Some(&context.meta),
394 ClientMetadata {
395 session_id,
396 client_name,
397 client_version,
398 },
399 );
400 let span = tracing::Span::current();
401 span.record("gen_ai.system", "mcp");
402 span.record("gen_ai.operation.name", "execute_tool");
403 span.record("gen_ai.tool.name", "analyze_file");
404 span.record("path", ¶ms.path);
405 let _validated_path = match validate_path(¶ms.path, true) {
406 Ok(p) => p,
407 Err(e) => {
408 span.record("error", true);
409 span.record("error.type", "invalid_params");
410 return Ok(err_to_tool_result(e));
411 }
412 };
413 let param_path = params.path.clone();
414 let ctx = tools::AnalyzeFileContext {
415 cache: self.cache.clone(),
416 disk_cache: self.disk_cache.clone(),
417 metrics_tx: self.metrics_tx.clone(),
418 sid: sid.clone(),
419 };
420 tools::analyze_file::analyze_file_handler(
421 &ctx, params, seq, sid, t_start, param_path, &span,
422 )
423 .await
424 }
425
426 #[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))]
427 #[tool(
428 name = "analyze_symbol",
429 title = "Analyze Symbol",
430 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.",
431 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
432 annotations(
433 title = "Analyze Symbol",
434 read_only_hint = true,
435 destructive_hint = false,
436 idempotent_hint = true,
437 open_world_hint = false
438 )
439 )]
440 async fn analyze_symbol(
441 &self,
442 params: Parameters<AnalyzeSymbolParams>,
443 context: RequestContext<RoleServer>,
444 ) -> Result<CallToolResult, ErrorData> {
445 let params = params.0;
446 let t_start = std::time::Instant::now();
447 let (seq, sid) = self.emit_received_metric("analyze_symbol").await;
448 let session_id = self.session_id.lock().await.clone();
449 let client_name = self.client_name.lock().await.clone();
450 let client_version = self.client_version.lock().await.clone();
451 extract_and_set_trace_context(
452 Some(&context.meta),
453 ClientMetadata {
454 session_id,
455 client_name,
456 client_version,
457 },
458 );
459 let span = tracing::Span::current();
460 span.record("gen_ai.system", "mcp");
461 span.record("gen_ai.operation.name", "execute_tool");
462 span.record("gen_ai.tool.name", "analyze_symbol");
463 span.record("symbol", ¶ms.symbol);
464 let _validated_path = match validate_path(¶ms.path, true) {
465 Ok(p) => p,
466 Err(e) => {
467 span.record("error", true);
468 span.record("error.type", "invalid_params");
469 return Ok(err_to_tool_result(e));
470 }
471 };
472 let ct = context.ct.clone();
473 let param_path = params.path.clone();
474 let max_depth_val = params.follow_depth;
475 let ctx = tools::AnalyzeSymbolContext {
476 metrics_tx: self.metrics_tx.clone(),
477 call_graph_cache: self.call_graph_cache.clone(),
478 disk_cache: self.disk_cache.clone(),
479 sid: sid.clone(),
480 seq,
481 };
482 let call = tools::AnalyzeSymbolCall {
483 ct,
484 param_path,
485 max_depth_val,
486 span,
487 t_start,
488 };
489 tools::analyze_symbol::analyze_symbol_handler(ctx, params, call).await
490 }
491
492 #[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))]
493 #[tool(
494 name = "analyze_module",
495 title = "Analyze Module",
496 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.",
497 output_schema = schema_for_type::<types::ModuleInfo>(),
498 annotations(
499 title = "Analyze Module",
500 read_only_hint = true,
501 destructive_hint = false,
502 idempotent_hint = true,
503 open_world_hint = false
504 )
505 )]
506 async fn analyze_module(
507 &self,
508 params: Parameters<AnalyzeModuleParams>,
509 context: RequestContext<RoleServer>,
510 ) -> Result<CallToolResult, ErrorData> {
511 let params = params.0;
512 let t_start = std::time::Instant::now();
513 let (seq, sid) = self.emit_received_metric("analyze_module").await;
514 let session_id = self.session_id.lock().await.clone();
515 let client_name = self.client_name.lock().await.clone();
516 let client_version = self.client_version.lock().await.clone();
517 extract_and_set_trace_context(
518 Some(&context.meta),
519 ClientMetadata {
520 session_id,
521 client_name,
522 client_version,
523 },
524 );
525 let span = tracing::Span::current();
526 span.record("gen_ai.system", "mcp");
527 span.record("gen_ai.operation.name", "execute_tool");
528 span.record("gen_ai.tool.name", "analyze_module");
529 span.record("path", ¶ms.path);
530 let _validated_path = match validate_path(¶ms.path, true) {
531 Ok(p) => p,
532 Err(e) => {
533 span.record("error", true);
534 span.record("error.type", "invalid_params");
535 return Ok(err_to_tool_result(e));
536 }
537 };
538 let param_path = params.path.clone();
539 let ctx = tools::AnalyzeModuleContext {
540 disk_cache: self.disk_cache.clone(),
541 metrics_tx: self.metrics_tx.clone(),
542 sid: sid.clone(),
543 seq,
544 };
545 tools::analyze_module::analyze_module_handler(ctx, params, param_path, &span, t_start).await
546 }
547
548 #[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))]
549 #[tool(
550 name = "edit_overwrite",
551 title = "Edit Overwrite",
552 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).",
553 output_schema = schema_for_type::<EditOverwriteOutput>(),
554 annotations(
555 title = "Edit Overwrite",
556 read_only_hint = false,
557 destructive_hint = true,
558 idempotent_hint = false,
559 open_world_hint = false
560 )
561 )]
562 async fn edit_overwrite(
563 &self,
564 params: Parameters<EditOverwriteParams>,
565 context: RequestContext<RoleServer>,
566 ) -> Result<CallToolResult, ErrorData> {
567 let params = params.0;
568 let t_start = std::time::Instant::now();
569 let (seq, sid) = self.emit_received_metric("edit_overwrite").await;
570 let session_id = self.session_id.lock().await.clone();
572 let client_name = self.client_name.lock().await.clone();
573 let client_version = self.client_version.lock().await.clone();
574 extract_and_set_trace_context(
575 Some(&context.meta),
576 ClientMetadata {
577 session_id,
578 client_name,
579 client_version,
580 },
581 );
582 let span = tracing::Span::current();
583 span.record("gen_ai.system", "mcp");
584 span.record("gen_ai.operation.name", "execute_tool");
585 tools::edit_overwrite::edit_overwrite(
586 params,
587 tools::EditHandlerContext {
588 sid,
589 seq,
590 cache: &self.cache,
591 metrics_tx: &self.metrics_tx,
592 edit_failure_counts: &self.edit_failure_counts,
593 },
594 &span,
595 t_start,
596 )
597 .await
598 }
599
600 #[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))]
601 #[tool(
602 name = "edit_replace",
603 title = "Edit Replace",
604 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).",
605 output_schema = schema_for_type::<EditReplaceOutput>(),
606 annotations(
607 title = "Edit Replace",
608 read_only_hint = false,
609 destructive_hint = true,
610 idempotent_hint = false,
611 open_world_hint = false
612 )
613 )]
614 async fn edit_replace(
615 &self,
616 params: Parameters<EditReplaceParams>,
617 context: RequestContext<RoleServer>,
618 ) -> Result<CallToolResult, ErrorData> {
619 let params = params.0;
620 let t_start = std::time::Instant::now();
621 let (seq, sid) = self.emit_received_metric("edit_replace").await;
622 let session_id = self.session_id.lock().await.clone();
624 let client_name = self.client_name.lock().await.clone();
625 let client_version = self.client_version.lock().await.clone();
626 extract_and_set_trace_context(
627 Some(&context.meta),
628 ClientMetadata {
629 session_id,
630 client_name,
631 client_version,
632 },
633 );
634 let span = tracing::Span::current();
635 span.record("gen_ai.system", "mcp");
636 span.record("gen_ai.operation.name", "execute_tool");
637 tools::edit_replace::edit_replace(
638 params,
639 tools::EditHandlerContext {
640 sid,
641 seq,
642 cache: &self.cache,
643 metrics_tx: &self.metrics_tx,
644 edit_failure_counts: &self.edit_failure_counts,
645 },
646 &span,
647 t_start,
648 )
649 .await
650 }
651
652 #[tool(
653 name = "exec_command",
654 title = "Exec Command",
655 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.",
656 output_schema = schema_for_type::<ShellOutput>(),
657 annotations(
658 title = "Exec Command",
659 read_only_hint = false,
660 destructive_hint = true,
661 idempotent_hint = false,
662 open_world_hint = true
663 )
664 )]
665 #[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))]
666 pub async fn exec_command(
667 &self,
668 params: Parameters<ExecCommandParams>,
669 context: RequestContext<RoleServer>,
670 ) -> Result<CallToolResult, ErrorData> {
671 let t_start = std::time::Instant::now();
672 let (seq, sid) = self.emit_received_metric("exec_command").await;
673 let params = params.0;
674 let session_id = self.session_id.lock().await.clone();
675 let client_name = self.client_name.lock().await.clone();
676 let client_version = self.client_version.lock().await.clone();
677 let ctx = crate::tools::exec_command::ExecContext {
678 seq,
679 sid,
680 session_id,
681 client_name,
682 client_version,
683 resolved_path: self.resolved_path.as_ref().as_deref().map(str::to_owned),
684 filter_table: self.filter_table.clone(),
685 metrics_tx: self.metrics_tx.clone(),
686 t_start,
687 };
688 crate::tools::exec_command::exec_command_impl(params, context, ctx).await
689 }
690}
691
692#[tool_handler]
693impl ServerHandler for CodeAnalyzer {
694 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
695 async fn initialize(
696 &self,
697 request: InitializeRequestParams,
698 context: RequestContext<RoleServer>,
699 ) -> Result<InitializeResult, ErrorData> {
700 let span = tracing::Span::current();
701 span.record("service.name", "aptu-coder");
702 span.record("service.version", env!("CARGO_PKG_VERSION"));
703
704 {
706 let mut client_name_lock = self.client_name.lock().await;
707 *client_name_lock = Some(request.client_info.name.clone());
708 }
709 {
710 let mut client_version_lock = self.client_version.lock().await;
711 *client_version_lock = Some(request.client_info.version.clone());
712 }
713
714 if let Some(meta) = context.extensions.get::<Meta>()
716 && let Some(profile) = meta
717 .0
718 .get("io.clouatre-labs/profile")
719 .and_then(|v| v.as_str())
720 {
721 let _ = self.session_profile.set(profile.to_owned());
722 }
723 Ok(self.get_info())
724 }
725
726 fn get_info(&self) -> InitializeResult {
727 let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
728 let instructions = format!(
729 "Recommended workflow:\n\
730 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
731 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\
732 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
733 4. Use analyze_symbol to trace call graphs.\n\
734 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\
735 JSONL metrics at $HOME/.local/share/aptu-coder/ (or $XDG_DATA_HOME/aptu-coder/). Always cd there before jq glob queries."
736 );
737 let capabilities = ServerCapabilities::builder()
738 .enable_tools()
739 .enable_tool_list_changed()
740 .enable_completions()
741 .build();
742 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
743 .with_title("Aptu Coder")
744 .with_description("MCP server for code structure analysis using tree-sitter");
745 InitializeResult::new(capabilities)
746 .with_server_info(server_info)
747 .with_instructions(&instructions)
748 }
749
750 async fn list_tools(
751 &self,
752 _request: Option<rmcp::model::PaginatedRequestParams>,
753 _context: RequestContext<RoleServer>,
754 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
755 let router = self.tool_router.read().await;
756 Ok(rmcp::model::ListToolsResult {
757 tools: router.list_all(),
758 meta: None,
759 next_cursor: None,
760 })
761 }
762
763 async fn call_tool(
764 &self,
765 request: rmcp::model::CallToolRequestParams,
766 context: RequestContext<RoleServer>,
767 ) -> Result<CallToolResult, ErrorData> {
768 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
769 let router = self.tool_router.read().await;
770 router.call(tcc).await
771 }
772
773 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
774 crate::tools::server::on_initialized_impl(
775 self.peer.clone(),
776 self.session_id.clone(),
777 self.session_call_seq.clone(),
778 self.session_profile.clone(),
779 self.tool_router.clone(),
780 &context.peer,
781 )
782 .await;
783 }
784
785 #[instrument(skip(self, _context))]
786 async fn on_cancelled(
787 &self,
788 notification: CancelledNotificationParam,
789 _context: NotificationContext<RoleServer>,
790 ) {
791 tracing::info!(
792 request_id = ?notification.request_id,
793 reason = ?notification.reason,
794 "Received cancellation notification"
795 );
796 }
797
798 #[instrument(skip(self, _context))]
799 async fn complete(
800 &self,
801 request: CompleteRequestParams,
802 _context: RequestContext<RoleServer>,
803 ) -> Result<CompleteResult, ErrorData> {
804 let argument_name = &request.argument.name;
806 let argument_value = &request.argument.value;
807
808 let completions = match argument_name.as_str() {
809 "path" => {
810 let root = Path::new(".");
812 completion::path_completions(root, argument_value)
813 }
814 "symbol" => {
815 let path_arg = request
817 .context
818 .as_ref()
819 .and_then(|ctx| ctx.get_argument("path"));
820
821 match path_arg {
822 Some(path_str) => {
823 let path = Path::new(path_str);
824 completion::symbol_completions(&self.cache, path, argument_value)
825 }
826 None => Vec::new(),
827 }
828 }
829 _ => Vec::new(),
830 };
831
832 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
834 let (values, has_more) = if completions.len() > 100 {
835 (completions.into_iter().take(100).collect(), true)
836 } else {
837 (completions, false)
838 };
839
840 let completion_info =
841 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
842 Ok(info) => info,
843 Err(_) => {
844 CompletionInfo::with_all_values(Vec::new())
846 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
847 }
848 };
849
850 Ok(CompleteResult::new(completion_info))
851 }
852
853 async fn set_level(
854 &self,
855 params: SetLevelRequestParams,
856 _context: RequestContext<RoleServer>,
857 ) -> Result<(), ErrorData> {
858 let level_filter = match params.level {
859 LoggingLevel::Debug => LevelFilter::DEBUG,
860 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
861 LoggingLevel::Warning => LevelFilter::WARN,
862 LoggingLevel::Error
863 | LoggingLevel::Critical
864 | LoggingLevel::Alert
865 | LoggingLevel::Emergency => LevelFilter::ERROR,
866 };
867
868 let mut filter_lock = self
869 .log_level_filter
870 .lock()
871 .unwrap_or_else(|e| e.into_inner());
872 *filter_lock = level_filter;
873 Ok(())
874 }
875}
876
877#[cfg(test)]
878mod tests;