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