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