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