1use aether_project::PromptCatalog;
2use clap::Parser;
3use mcp_utils::client::{InMemoryServerConfig, McpConfig, McpServerConfig};
4use rmcp::{
5 RoleServer, ServerHandler,
6 handler::server::{
7 router::tool::ToolRouter,
8 wrapper::{Json, Parameters},
9 },
10 model::{
11 CreateElicitationRequestParams, ElicitationSchema, EnumSchema, Implementation, ProgressNotificationParam,
12 ServerCapabilities, ServerInfo,
13 },
14 service::RequestContext,
15 tool, tool_handler, tool_router,
16};
17use std::fmt::Write as _;
18use std::path::PathBuf;
19use std::{
20 collections::{HashMap, HashSet},
21 path::Path,
22 sync::Arc,
23};
24use tokio::{
25 fs::try_exists,
26 sync::{Mutex, RwLock},
27};
28
29pub mod default_tools;
30pub mod error;
31pub mod prompt_rule_matcher;
32pub mod tools;
33pub mod tools_trait;
34
35pub use default_tools::DefaultCodingTools;
36pub use tools_trait::CodingTools;
37
38use crate::lsp::tools::symbol_lookup::{LspSymbolInput, LspSymbolOutput, execute_lsp_symbol};
39use crate::lsp::tools::workspace_search::{
40 LspWorkspaceSearchInput, LspWorkspaceSearchOutput, execute_lsp_workspace_search,
41};
42use crate::{coding::prompt_rule_matcher::PromptRuleMatcher, lsp::registry::LspRegistry};
43use crate::{
44 error::ServerInitError,
45 lsp::tools::rename::{LspRenameInput, LspRenameOutput, execute_lsp_rename},
46};
47use crate::{
48 lsp::tools::check_errors::{
49 LspDiagnosticsInput, LspDiagnosticsOutput, LspDiagnosticsRequest, execute_lsp_diagnostics,
50 },
51 workspace_paths::primary_root,
52};
53use crate::{
54 lsp::tools::document_info::{LspDocumentInput, LspDocumentOutput, execute_lsp_document},
55 workspace_paths::{resolve_dir, resolve_file},
56};
57
58use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta, basename, truncate};
59use tools::ast_grep::{AstGrepInput, AstGrepOutput, perform_ast_grep};
60use tools::bash::{
61 BackgroundProcessHandle, BashInput, BashOutput, BashResult, ReadBackgroundBashInput, ReadBackgroundBashOutput,
62 execute_command_in_dir, read_background_bash,
63};
64use tools::edit_file::{EditFileArgs, EditFileResponse, edit_file_contents};
65use tools::find::{FindInput, FindOutput, find_files_by_name};
66use tools::grep::{GrepInput, GrepOutput, perform_grep};
67use tools::list_files::{ListFilesArgs, ListFilesResult, list_files};
68use tools::read_file::{ReadFileArgs, ReadFileResult, read_file_contents};
69use tools::web_fetch::{WebFetchInput, WebFetchOutput, WebFetcher};
70use tools::web_search::search_client::BraveSearchClient;
71use tools::web_search::{WebSearchInput, WebSearchOutput, WebSearcher};
72use tools::write_file::{WriteFileArgs, WriteFileResponse, write_file_contents};
73
74trait IntoMcpResult<T> {
76 fn into_mcp(self) -> Result<Json<T>, String>;
77}
78
79impl<T, E: std::fmt::Display> IntoMcpResult<T> for Result<T, E> {
80 fn into_mcp(self) -> Result<Json<T>, String> {
81 self.map(Json).map_err(|e| e.to_string())
82 }
83}
84
85#[doc = include_str!("../docs/permission_mode.md")]
86#[derive(Debug, Clone, Default, PartialEq, clap::ValueEnum)]
87pub enum PermissionMode {
88 #[default]
90 AlwaysAllow,
91 Auto,
93 AlwaysAsk,
95}
96
97#[derive(Debug, Clone, Default, Parser)]
99pub struct CodingMcpArgs {
100 #[arg(long = "root-dir")]
102 pub root_dir: Option<PathBuf>,
103
104 #[arg(long = "rules-dir")]
107 pub rules_dirs: Vec<PathBuf>,
108
109 #[arg(long = "permission-mode", default_value = "always-allow")]
111 pub permission_mode: PermissionMode,
112
113 #[arg(long = "disable-lsp")]
115 pub disable_lsp: bool,
116}
117
118impl CodingMcpArgs {
119 pub fn from_args(args: Vec<String>) -> Result<Self, ServerInitError> {
120 let mut full_args = vec!["coding-mcp".to_string()];
121 full_args.extend(args);
122
123 Self::try_parse_from(full_args).map_err(ServerInitError::InvalidArgs)
124 }
125
126 pub fn parse_root_dir_from_config(mcp_config_path: &Path) -> Option<PathBuf> {
131 let raw_config = McpConfig::from_json_file(mcp_config_path).ok()?;
132 let McpServerConfig::InMemory(InMemoryServerConfig { args, .. }) = raw_config.servers.get("coding")? else {
133 return None;
134 };
135
136 let parsed_args = Self::from_args(args.clone()).ok()?;
137 let root_dir = parsed_args.root_dir?;
138
139 if root_dir.is_relative() {
140 let config_dir = mcp_config_path.parent()?;
141 Some(config_dir.join(&root_dir).canonicalize().ok()?)
142 } else {
143 Some(root_dir)
144 }
145 }
146}
147
148#[doc = include_str!("../docs/coding_mcp.md")]
149#[derive(Debug)]
150pub struct CodingMcp<T: CodingTools = DefaultCodingTools> {
151 tool_router: ToolRouter<Self>,
152 background_processes: Mutex<HashMap<String, BackgroundProcessHandle>>,
153 files_read: RwLock<HashSet<String>>,
155 tools: T,
156 lsp: Option<Arc<LspRegistry>>,
158 web_fetcher: WebFetcher,
159 web_searcher: Option<WebSearcher<BraveSearchClient>>,
160 roots: RwLock<Vec<PathBuf>>,
162 read_rule_state: prompt_rule_matcher::PromptRuleMatcher,
164 configured_rules_dirs: Vec<PathBuf>,
166 permission_mode: PermissionMode,
168}
169
170fn build_rule_catalog(configured_rules_dirs: &[PathBuf]) -> aether_project::PromptCatalog {
171 if configured_rules_dirs.is_empty() {
172 return aether_project::PromptCatalog::empty();
173 }
174
175 PromptCatalog::from_dirs(configured_rules_dirs)
176}
177
178#[tool_handler(router = self.tool_router)]
179impl<T: CodingTools + 'static> ServerHandler for CodingMcp<T> {
180 fn get_info(&self) -> ServerInfo {
181 let instructions = self.build_instructions();
182 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
183 .with_server_info(Implementation::new("coding-mcp", "0.1.0"))
184 .with_instructions(instructions)
185 }
186}
187
188impl CodingMcp<DefaultCodingTools> {
189 pub fn new() -> Self {
191 Self::with_tools(DefaultCodingTools::new())
192 }
193}
194
195async fn notify_preview(context: &RequestContext<RoleServer>, meta: ToolDisplayMeta) {
196 if let Some(token) = context.meta.get_progress_token() {
197 let result_meta = ToolResultMeta::from(meta);
198 let message = serde_json::to_string(&result_meta).unwrap_or_default();
199 let _ = context
200 .peer
201 .notify_progress(ProgressNotificationParam {
202 progress_token: token,
203 progress: 0.0,
204 total: None,
205 message: Some(message),
206 })
207 .await;
208 }
209}
210
211fn is_dangerous_cmd(command: &str) -> bool {
215 const PATTERNS: &[&str] = &[
216 "rm ",
217 "rm\t",
218 "rmdir ",
219 "git push",
220 "git reset",
221 "git checkout --",
222 "git clean",
223 "chmod ",
224 "chown ",
225 "kill ",
226 "pkill ",
227 "mv ",
228 "dd ",
229 "--force",
230 "--hard",
231 ];
232
233 if PATTERNS.iter().any(|p| command.contains(p)) {
235 return true;
236 }
237
238 for (i, _) in command.match_indices("> ") {
241 if i == 0 || command.as_bytes()[i - 1] != b'=' {
242 return true;
243 }
244 }
245
246 false
247}
248
249#[tool_router]
250impl<T: CodingTools + 'static> CodingMcp<T> {
251 pub fn with_tools(tools: T) -> Self {
253 Self {
254 tool_router: Self::tool_router(),
255 background_processes: Mutex::new(HashMap::new()),
256 files_read: RwLock::new(HashSet::new()),
257 tools,
258 lsp: None,
259 web_fetcher: WebFetcher::new(),
260 web_searcher: WebSearcher::try_new().ok(),
261 roots: RwLock::new(Vec::new()),
262 read_rule_state: prompt_rule_matcher::PromptRuleMatcher::default(),
263 configured_rules_dirs: Vec::new(),
264 permission_mode: PermissionMode::AlwaysAllow,
265 }
266 }
267
268 pub fn with_lsp(mut self, root_path: PathBuf) -> Self {
273 self.lsp = Some(LspRegistry::new_and_spawn(root_path));
274 self
275 }
276
277 pub fn with_roots(mut self, roots: Vec<PathBuf>) -> Self {
279 let catalog = build_rule_catalog(&self.configured_rules_dirs);
280 self.read_rule_state = prompt_rule_matcher::PromptRuleMatcher::new(catalog);
281 self.roots = RwLock::new(roots);
282 self
283 }
284
285 pub fn with_rules_dirs(mut self, rules_dirs: Vec<PathBuf>) -> Self {
287 self.configured_rules_dirs = rules_dirs;
288 let catalog = build_rule_catalog(&self.configured_rules_dirs);
289 self.read_rule_state = PromptRuleMatcher::new(catalog);
290 self
291 }
292
293 pub fn with_root_dir(self, root_dir: PathBuf) -> Self {
295 self.with_roots(vec![root_dir])
296 }
297
298 pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
300 self.permission_mode = mode;
301 self
302 }
303
304 fn get_workspace_root(&self) -> Option<PathBuf> {
306 self.roots.try_read().ok().and_then(|roots| roots.first().cloned())
307 }
308
309 async fn primary_workspace_root(&self) -> PathBuf {
310 let roots = self.roots.read().await;
311 primary_root(&roots)
312 }
313
314 fn build_instructions(&self) -> String {
315 let mut base = String::from(
316 r"# Coding MCP Server
317
318File I/O, search, shell, and optional LSP code intelligence tools for coding workflows.
319
320## Quick Reference
321
322- **Text patterns** (TODOs, logs, strings): `grep`
323- **Structural code patterns** (AST search): `ast_grep`
324- **File names** (find *.test.ts): `find`
325- **Read/write/edit** files: `read_file`, `write_file`, `edit_file`
326- **Shell commands**: `bash`
327",
328 );
329
330 if self.lsp.is_some() {
331 base.push_str(
332 r"- **Errors & warnings** (instant check without build): `lsp_check_errors`
333- **Code symbols** (definitions, usages, types): `lsp_symbol`
334- **Find symbol across workspace** (don't know the file?): `lsp_workspace_search`
335- **File structure** (what's in this file?): `lsp_document`
336- **Rename symbol** (refactor across codebase): `lsp_rename`
337",
338 );
339 }
340
341 match self.get_workspace_root() {
342 Some(root) => format!(
343 r"{}
344
345When using tools that take file paths, always use absolute paths from:
346<workspace-root>{}</workspace-root>",
347 base,
348 root.display()
349 ),
350 None => base,
351 }
352 }
353
354 async fn elicit_permission(
360 &self,
361 context: &RequestContext<RoleServer>,
362 tool_name: &str,
363 description: &str,
364 ) -> Result<(), String> {
365 let message = format!("Allow {tool_name}: {description}?");
366 let result = context
367 .peer
368 .create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
369 meta: None,
370 message,
371 requested_schema: ElicitationSchema::builder()
372 .required_enum_schema(
373 "decision",
374 EnumSchema::builder(vec!["allow".into(), "deny".into()])
375 .untitled()
376 .with_default("deny")
377 .unwrap()
378 .build(),
379 )
380 .build()
381 .unwrap(),
382 })
383 .await
384 .map_err(|e| format!("Elicitation failed: {e}"))?;
385
386 let allowed = result.content.as_ref().and_then(|c| c.get("decision")).and_then(|v| v.as_str()) == Some("allow");
387
388 if allowed { Ok(()) } else { Err(format!("Operation declined by user: {tool_name}")) }
389 }
390
391 async fn check_bash_permission(&self, context: &RequestContext<RoleServer>, command: &str) -> Result<(), String> {
394 match self.permission_mode {
395 PermissionMode::AlwaysAllow => Ok(()),
396 PermissionMode::AlwaysAsk => self.elicit_permission(context, "bash", command).await,
397 PermissionMode::Auto => {
398 if is_dangerous_cmd(command) {
399 self.elicit_permission(context, "bash", command).await
400 } else {
401 Ok(())
402 }
403 }
404 }
405 }
406
407 async fn check_write_permission(
410 &self,
411 context: &RequestContext<RoleServer>,
412 tool_name: &str,
413 file_path: &str,
414 ) -> Result<(), String> {
415 if self.permission_mode == PermissionMode::AlwaysAsk {
416 self.elicit_permission(context, tool_name, file_path).await
417 } else {
418 Ok(())
419 }
420 }
421
422 fn spawn_diagnostic_refresh(&self, file_path: &str) {
423 if let Some(lsp) = &self.lsp {
424 let lsp = Arc::clone(lsp);
425 let file_path = file_path.to_string();
426 tokio::spawn(async move {
427 lsp.queue_diagnostic_refresh(&file_path).await;
428 });
429 }
430 }
431
432 async fn resolve_file_arg(&self, raw: &str) -> Result<String, String> {
435 let root = self.primary_workspace_root().await;
436 Ok(resolve_file(&root, raw).map_err(|e| e.to_string())?.to_string_lossy().to_string())
437 }
438
439 async fn read_and_track(&self, args: ReadFileArgs) -> Result<Json<ReadFileResult>, String> {
442 let file_path = args.file_path.clone();
443 let mut result = self.tools.read_file(args).await.map_err(|e| e.to_string())?;
444 self.files_read.write().await.insert(file_path.clone());
445
446 let total_lines = result.total_lines;
447 let roots = self.roots.read().await;
448 let matched = self.read_rule_state.get_matched_rules(&roots, &file_path);
449 for rule in &matched {
450 write!(result.content, "\n\n<system-reminder>\n{}\n</system-reminder>", rule.body).unwrap();
451 }
452
453 if !matched.is_empty() {
454 let rule_names: Vec<&str> = matched.iter().map(|r| r.name.as_str()).collect();
455 let base = format!("{}, {total_lines} lines", basename(&file_path));
456 let value = format!("{base} +rules: {}", rule_names.join(", "));
457 result.meta = Some(ToolDisplayMeta::new("Read file", value).into());
458 }
459
460 Ok(Json(result))
461 }
462
463 async fn ensure_read_before_overwrite(&self, file_path: &str) -> Result<(), String> {
466 if try_exists(file_path).await.map_err(|e| format!("Failed to check existence of {file_path}: {e}"))?
467 && !self.files_read.read().await.contains(file_path)
468 {
469 return Err(format!(
470 "Safety check failed: File '{file_path}' already exists. You must use read_file on it before overwriting. This prevents accidental data loss."
471 ));
472 }
473 Ok(())
474 }
475
476 async fn ensure_read_before_edit(&self, file_path: &str) -> Result<(), String> {
478 if !self.files_read.read().await.contains(file_path) {
479 return Err(format!(
480 "Safety check failed: You must use read_file on '{file_path}' before editing it. This ensures you understand the current file contents before making changes."
481 ));
482 }
483 Ok(())
484 }
485
486 #[doc = include_str!("tools/grep/description.md")]
487 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
488 pub async fn grep(
489 &self,
490 request: Parameters<GrepInput>,
491 context: RequestContext<RoleServer>,
492 ) -> Result<Json<GrepOutput>, String> {
493 let Parameters(mut args) = request;
494 let root = self.primary_workspace_root().await;
495 let normalized_path = resolve_dir(&root, args.path.as_deref());
496 args.path = Some(normalized_path.to_string_lossy().to_string());
497 notify_preview(&context, ToolDisplayMeta::new("Grep", format!("'{}'", args.pattern))).await;
498 self.tools.grep(args).await.into_mcp()
499 }
500
501 #[doc = include_str!("tools/ast_grep/description.md")]
502 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
503 pub async fn ast_grep(
504 &self,
505 request: Parameters<AstGrepInput>,
506 context: RequestContext<RoleServer>,
507 ) -> Result<Json<AstGrepOutput>, String> {
508 let Parameters(mut args) = request;
509 let root = self.primary_workspace_root().await;
510 let normalized_path = resolve_dir(&root, args.path.as_deref());
511 args.path = Some(normalized_path.to_string_lossy().to_string());
512 notify_preview(&context, ToolDisplayMeta::new("AST grep", format!("'{}'", args.pattern))).await;
513 self.tools.ast_grep(args).await.into_mcp()
514 }
515
516 #[doc = include_str!("tools/find/description.md")]
517 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
518 pub async fn find(
519 &self,
520 request: Parameters<FindInput>,
521 context: RequestContext<RoleServer>,
522 ) -> Result<Json<FindOutput>, String> {
523 let Parameters(mut args) = request;
524 let root = self.primary_workspace_root().await;
525 let normalized_path = resolve_dir(&root, args.path.as_deref());
526 args.path = Some(normalized_path.to_string_lossy().to_string());
527 notify_preview(&context, ToolDisplayMeta::new("Find", format!("'{}'", args.pattern))).await;
528 self.tools.find(args).await.into_mcp()
529 }
530
531 #[doc = include_str!("tools/read_file/description.md")]
532 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
533 pub async fn read_file(
534 &self,
535 request: Parameters<ReadFileArgs>,
536 context: RequestContext<RoleServer>,
537 ) -> Result<Json<ReadFileResult>, String> {
538 let Parameters(mut args) = request;
539 args.file_path = self.resolve_file_arg(&args.file_path).await?;
540 notify_preview(&context, ToolDisplayMeta::new("Read file", basename(&args.file_path))).await;
541 self.read_and_track(args).await
542 }
543
544 #[doc = include_str!("tools/write_file/description.md")]
545 #[tool(annotations(
546 read_only_hint = false,
547 destructive_hint = true,
548 idempotent_hint = false,
549 open_world_hint = false
550 ))]
551 pub async fn write_file(
552 &self,
553 request: Parameters<WriteFileArgs>,
554 context: RequestContext<RoleServer>,
555 ) -> Result<Json<WriteFileResponse>, String> {
556 let Parameters(mut args) = request;
557 args.file_path = self.resolve_file_arg(&args.file_path).await?;
558 notify_preview(&context, ToolDisplayMeta::new("Write file", basename(&args.file_path))).await;
559
560 self.check_write_permission(&context, "write_file", &args.file_path).await?;
561 self.ensure_read_before_overwrite(&args.file_path).await?;
562
563 let response = self.tools.write_file(args).await.map_err(|e| e.to_string())?;
564
565 self.spawn_diagnostic_refresh(&response.file_path);
566
567 Ok(Json(response))
568 }
569
570 #[doc = include_str!("tools/edit_file/description.md")]
571 #[tool(annotations(
572 read_only_hint = false,
573 destructive_hint = true,
574 idempotent_hint = false,
575 open_world_hint = false
576 ))]
577 pub async fn edit_file(
578 &self,
579 request: Parameters<EditFileArgs>,
580 context: RequestContext<RoleServer>,
581 ) -> Result<Json<EditFileResponse>, String> {
582 let Parameters(mut args) = request;
583 args.file_path = self.resolve_file_arg(&args.file_path).await?;
584 notify_preview(&context, ToolDisplayMeta::new("Edit file", basename(&args.file_path))).await;
585
586 self.check_write_permission(&context, "edit_file", &args.file_path).await?;
587 self.ensure_read_before_edit(&args.file_path).await?;
588
589 let response = self.tools.edit_file(args).await.map_err(|e| e.to_string())?;
590
591 self.spawn_diagnostic_refresh(&response.file_path);
592
593 Ok(Json(response))
594 }
595
596 #[doc = include_str!("tools/list_files/description.md")]
597 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
598 pub async fn list_files(
599 &self,
600 request: Parameters<ListFilesArgs>,
601 context: RequestContext<RoleServer>,
602 ) -> Result<Json<ListFilesResult>, String> {
603 let Parameters(mut args) = request;
604 let root = self.primary_workspace_root().await;
605 let normalized_path = resolve_dir(&root, args.path.as_deref());
606 let preview_value = basename(&normalized_path.to_string_lossy());
607 args.path = Some(normalized_path.to_string_lossy().to_string());
608 notify_preview(&context, ToolDisplayMeta::new("List files", preview_value)).await;
609 self.tools.list_files(args).await.into_mcp()
610 }
611
612 #[doc = include_str!("tools/bash/description.md")]
613 #[tool(annotations(
614 read_only_hint = false,
615 destructive_hint = true,
616 idempotent_hint = false,
617 open_world_hint = true
618 ))]
619 pub async fn bash(
620 &self,
621 request: Parameters<BashInput>,
622 context: RequestContext<RoleServer>,
623 ) -> Result<Json<BashOutput>, String> {
624 let Parameters(args) = request;
625 notify_preview(&context, ToolDisplayMeta::new("Run command", truncate(&args.command, 40))).await;
626
627 self.check_bash_permission(&context, &args.command).await?;
628
629 let command = args.command.clone();
630 let cwd = self.primary_workspace_root().await;
631 let result = self.tools.bash(args, Some(cwd)).await.map_err(|e| e.to_string())?;
632
633 match result {
634 BashResult::Completed(output) => Ok(Json(output)),
635 BashResult::Background(handle) => {
636 let shell_id = handle.shell_id.clone();
637
638 self.background_processes.lock().await.insert(shell_id.clone(), handle);
640
641 let display_meta =
642 ToolDisplayMeta::new("Run command", format!("{} (background)", truncate(&command, 40)));
643
644 Ok(Json(BashOutput {
646 output: String::new(),
647 exit_code: 0,
648 killed: None,
649 shell_id: Some(shell_id),
650 meta: Some(display_meta.into()),
651 }))
652 }
653 }
654 }
655
656 #[doc = include_str!("tools/bash/read_background_description.md")]
657 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
658 pub async fn read_background_bash(
659 &self,
660 request: Parameters<ReadBackgroundBashInput>,
661 ) -> Result<Json<ReadBackgroundBashOutput>, String> {
662 let Parameters(args) = request;
663
664 let handle = self
665 .background_processes
666 .lock()
667 .await
668 .remove(&args.bash_id)
669 .ok_or_else(|| format!("Shell ID not found: {}", args.bash_id))?;
670
671 let (result, handle_opt) =
672 self.tools.read_background_bash(handle, args.filter).await.map_err(|e| e.to_string())?;
673
674 if let Some(handle) = handle_opt {
676 self.background_processes.lock().await.insert(args.bash_id, handle);
677 }
678
679 Ok(Json(result))
680 }
681
682 #[doc = include_str!("tools/web_fetch/description.md")]
683 #[tool(annotations(read_only_hint = true, open_world_hint = true))]
684 pub async fn web_fetch(
685 &self,
686 request: Parameters<WebFetchInput>,
687 context: RequestContext<RoleServer>,
688 ) -> Result<Json<WebFetchOutput>, String> {
689 let Parameters(args) = request;
690 notify_preview(&context, ToolDisplayMeta::new("Fetch URL", truncate(&args.url, 60))).await;
691 self.web_fetcher.fetch(args).await.into_mcp()
692 }
693
694 #[doc = include_str!("tools/web_search/description.md")]
695 #[tool(annotations(read_only_hint = true, open_world_hint = true))]
696 pub async fn web_search(
697 &self,
698 request: Parameters<WebSearchInput>,
699 context: RequestContext<RoleServer>,
700 ) -> Result<Json<WebSearchOutput>, String> {
701 let Parameters(args) = request;
702 notify_preview(&context, ToolDisplayMeta::new("Web search", format!("'{}'", args.query))).await;
703
704 let searcher = self.web_searcher.as_ref().ok_or_else(|| {
705 "Web search not available: BRAVE_SEARCH_API_KEY environment variable not set. \
706 Get a free API key from https://api.search.brave.com/app/keys"
707 .to_string()
708 })?;
709
710 searcher.search(args).await.map_err(|e| e.to_string()).map(Json)
711 }
712
713 #[doc = include_str!("../lsp/tools/symbol_lookup/description.md")]
714 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
715 pub async fn lsp_symbol(
716 &self,
717 request: Parameters<LspSymbolInput>,
718 context: RequestContext<RoleServer>,
719 ) -> Result<Json<LspSymbolOutput>, String> {
720 let Parameters(input) = request;
721 notify_preview(&context, ToolDisplayMeta::new("LSP symbol", &input.symbol)).await;
722 let lsp = self.lsp.as_ref().ok_or("LSP not configured")?;
723 execute_lsp_symbol(input, lsp.as_ref()).await.map(Json)
724 }
725
726 #[doc = include_str!("../lsp/tools/workspace_search/description.md")]
727 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
728 pub async fn lsp_workspace_search(
729 &self,
730 request: Parameters<LspWorkspaceSearchInput>,
731 context: RequestContext<RoleServer>,
732 ) -> Result<Json<LspWorkspaceSearchOutput>, String> {
733 let Parameters(input) = request;
734 notify_preview(&context, ToolDisplayMeta::new("LSP search", format!("'{}'", input.query))).await;
735 let lsp = self.lsp.as_ref().ok_or("LSP not configured")?;
736 execute_lsp_workspace_search(input, lsp.as_ref()).await.map(Json)
737 }
738
739 #[doc = include_str!("../lsp/tools/document_info/description.md")]
740 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
741 pub async fn lsp_document(
742 &self,
743 request: Parameters<LspDocumentInput>,
744 context: RequestContext<RoleServer>,
745 ) -> Result<Json<LspDocumentOutput>, String> {
746 let Parameters(input) = request;
747 notify_preview(&context, ToolDisplayMeta::new("LSP document", basename(&input.file_path))).await;
748 let lsp = self.lsp.as_ref().ok_or("LSP not configured")?;
749 execute_lsp_document(input, lsp.as_ref()).await.map(Json)
750 }
751
752 #[doc = include_str!("../lsp/tools/check_errors/description.md")]
753 #[tool(annotations(read_only_hint = true, open_world_hint = false))]
754 pub async fn lsp_check_errors(
755 &self,
756 request: Parameters<LspDiagnosticsRequest>,
757 context: RequestContext<RoleServer>,
758 ) -> Result<Json<LspDiagnosticsOutput>, String> {
759 let Parameters(request) = request;
760 let preview_value = match &request.input {
761 LspDiagnosticsInput::Workspace {} => "workspace".to_string(),
762 LspDiagnosticsInput::File { file_path } => basename(file_path),
763 };
764 notify_preview(&context, ToolDisplayMeta::new("LSP errors", preview_value)).await;
765 let lsp = self.lsp.as_ref().ok_or("LSP not configured")?;
766 execute_lsp_diagnostics(request, lsp.as_ref()).await.map(Json)
767 }
768
769 #[doc = include_str!("../lsp/tools/rename/description.md")]
770 #[tool(annotations(
771 read_only_hint = false,
772 destructive_hint = true,
773 idempotent_hint = false,
774 open_world_hint = false
775 ))]
776 pub async fn lsp_rename(
777 &self,
778 request: Parameters<LspRenameInput>,
779 context: RequestContext<RoleServer>,
780 ) -> Result<Json<LspRenameOutput>, String> {
781 let Parameters(input) = request;
782 notify_preview(&context, ToolDisplayMeta::new("LSP rename", &input.symbol)).await;
783 let lsp = self.lsp.as_ref().ok_or("LSP not configured")?;
784 execute_lsp_rename(input, lsp.as_ref()).await.map(Json)
785 }
786}
787
788impl Default for CodingMcp<DefaultCodingTools> {
789 fn default() -> Self {
790 Self::new()
791 }
792}
793
794#[cfg(feature = "test-helpers")]
795impl<T: CodingTools + 'static> CodingMcp<T> {
796 pub async fn test_read_file(&self, mut args: ReadFileArgs) -> Result<Json<ReadFileResult>, String> {
798 args.file_path = self.resolve_file_arg(&args.file_path).await?;
799 self.read_and_track(args).await
800 }
801
802 pub async fn test_write_file(&self, mut args: WriteFileArgs) -> Result<Json<WriteFileResponse>, String> {
804 args.file_path = self.resolve_file_arg(&args.file_path).await?;
805 self.ensure_read_before_overwrite(&args.file_path).await?;
806 self.tools.write_file(args).await.map(Json).map_err(|e| e.to_string())
807 }
808
809 pub async fn test_edit_file(&self, mut args: EditFileArgs) -> Result<Json<EditFileResponse>, String> {
811 args.file_path = self.resolve_file_arg(&args.file_path).await?;
812 self.ensure_read_before_edit(&args.file_path).await?;
813 self.tools.edit_file(args).await.map(Json).map_err(|e| e.to_string())
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn args_default_permission_mode_is_always_allow() {
823 let args = CodingMcpArgs::from_args(vec![]).unwrap();
824 assert_eq!(args.permission_mode, PermissionMode::AlwaysAllow);
825 }
826
827 #[test]
828 fn args_default_lsp_enabled() {
829 let args = CodingMcpArgs::from_args(vec![]).unwrap();
830 assert!(!args.disable_lsp);
831 }
832
833 #[test]
834 fn args_parses_disable_lsp() {
835 let args = CodingMcpArgs::from_args(vec!["--disable-lsp".into()]).unwrap();
836 assert!(args.disable_lsp);
837 }
838
839 #[test]
840 fn disabled_lsp_instructions_omit_lsp_tools() {
841 let instructions = CodingMcp::new().build_instructions();
842 assert!(!instructions.contains("lsp_check_errors"));
843 assert!(!instructions.contains("lsp_symbol"));
844 }
845
846 #[test]
847 fn args_parses_always_allow() {
848 let args = CodingMcpArgs::from_args(vec!["--permission-mode".into(), "always-allow".into()]).unwrap();
849 assert_eq!(args.permission_mode, PermissionMode::AlwaysAllow);
850 }
851
852 #[test]
853 fn args_parses_auto() {
854 let args = CodingMcpArgs::from_args(vec!["--permission-mode".into(), "auto".into()]).unwrap();
855 assert_eq!(args.permission_mode, PermissionMode::Auto);
856 }
857
858 #[test]
859 fn args_parses_always_ask() {
860 let args = CodingMcpArgs::from_args(vec!["--permission-mode".into(), "always-ask".into()]).unwrap();
861 assert_eq!(args.permission_mode, PermissionMode::AlwaysAsk);
862 }
863
864 #[test]
865 fn args_rejects_invalid_permission_mode() {
866 assert!(CodingMcpArgs::from_args(vec!["--permission-mode".into(), "yolo".into()]).is_err());
867 }
868
869 #[test]
870 fn args_parses_repeated_rules_dirs() {
871 let args = CodingMcpArgs::from_args(vec![
872 "--rules-dir".into(),
873 ".aether/skills".into(),
874 "--rules-dir".into(),
875 ".claude/rules".into(),
876 ])
877 .unwrap();
878
879 assert_eq!(args.rules_dirs, vec![PathBuf::from(".aether/skills"), PathBuf::from(".claude/rules")]);
880 }
881
882 #[test]
883 fn with_permission_mode_stores_mode() {
884 let mcp = CodingMcp::new().with_permission_mode(PermissionMode::AlwaysAsk);
885 assert_eq!(mcp.permission_mode, PermissionMode::AlwaysAsk);
886 }
887
888 #[test]
889 fn default_permission_mode_is_always_allow() {
890 let mcp = CodingMcp::new();
891 assert_eq!(mcp.permission_mode, PermissionMode::AlwaysAllow);
892 }
893
894 #[test]
895 fn detects_rm() {
896 assert!(is_dangerous_cmd("rm -rf /tmp/foo"));
897 assert!(is_dangerous_cmd("rm\tfoo.txt"));
898 }
899
900 #[test]
901 fn detects_git_push() {
902 assert!(is_dangerous_cmd("git push origin main"));
903 }
904
905 #[test]
906 fn detects_git_reset() {
907 assert!(is_dangerous_cmd("git reset --hard HEAD~1"));
908 }
909
910 #[test]
911 fn detects_git_checkout_discard() {
912 assert!(is_dangerous_cmd("git checkout -- ."));
913 }
914
915 #[test]
916 fn detects_git_clean() {
917 assert!(is_dangerous_cmd("git clean -fd"));
918 }
919
920 #[test]
921 fn detects_redirect() {
922 assert!(is_dangerous_cmd("echo x > file.txt"));
923 assert!(is_dangerous_cmd("echo x >> file.txt"));
924 }
925
926 #[test]
927 fn does_not_flag_fat_arrow() {
928 assert!(!is_dangerous_cmd("grep '=> ' file.txt"));
929 }
930
931 #[test]
932 fn detects_chmod_chown() {
933 assert!(is_dangerous_cmd("chmod 777 /etc/passwd"));
934 assert!(is_dangerous_cmd("chown root:root /tmp/x"));
935 }
936
937 #[test]
938 fn detects_kill_signals() {
939 assert!(is_dangerous_cmd("kill -9 1234"));
940 assert!(is_dangerous_cmd("pkill node"));
941 }
942
943 #[test]
944 fn does_not_flag_kill_substring() {
945 assert!(!is_dangerous_cmd("echo skillset"));
946 }
947
948 #[test]
949 fn detects_mv() {
950 assert!(is_dangerous_cmd("mv old.txt new.txt"));
951 }
952
953 #[test]
954 fn detects_force_flags() {
955 assert!(is_dangerous_cmd("npm install --force"));
956 assert!(is_dangerous_cmd("git reset --hard"));
957 }
958
959 #[test]
960 fn detects_dd() {
961 assert!(is_dangerous_cmd("dd if=/dev/zero of=/dev/sda"));
962 }
963
964 #[test]
965 fn detects_rmdir() {
966 assert!(is_dangerous_cmd("rmdir empty_dir"));
967 }
968
969 #[test]
970 fn does_not_flag_redirect_in_output() {
971 assert!(is_dangerous_cmd("> file.txt"));
972 }
973
974 #[test]
975 fn allows_safe_commands() {
976 assert!(!is_dangerous_cmd("ls -la"));
977 assert!(!is_dangerous_cmd("cat foo.txt"));
978 assert!(!is_dangerous_cmd("git status"));
979 assert!(!is_dangerous_cmd("git diff"));
980 assert!(!is_dangerous_cmd("cargo test"));
981 assert!(!is_dangerous_cmd("grep -r pattern ."));
982 }
983}