Skip to main content

mcp_servers/coding/
mod.rs

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
74/// Extension trait for converting tool results to MCP format
75trait 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    /// Everything auto-executes (current default behavior).
89    #[default]
90    AlwaysAllow,
91    /// File writes auto-execute; destructive bash commands trigger elicitation.
92    Auto,
93    /// All write/edit/bash calls trigger elicitation; read-only tools are ungated.
94    AlwaysAsk,
95}
96
97/// CLI arguments for `CodingMcp` server
98#[derive(Debug, Clone, Default, Parser)]
99pub struct CodingMcpArgs {
100    /// Root directory for workspace (used for LSP initialization)
101    #[arg(long = "root-dir")]
102    pub root_dir: Option<PathBuf>,
103
104    /// Prompt directories to scan for automatic read-triggered rules.
105    /// Can be specified multiple times: --rules-dir .aether/skills --rules-dir .claude/rules
106    #[arg(long = "rules-dir")]
107    pub rules_dirs: Vec<PathBuf>,
108
109    /// Permission mode controlling user approval for tool calls
110    #[arg(long = "permission-mode", default_value = "always-allow")]
111    pub permission_mode: PermissionMode,
112
113    /// Disable LSP-backed coding tools and daemon connections.
114    #[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    /// Parse the root directory from an mcp.json config file.
127    ///
128    /// Looks for the "coding" server entry and parses its args for `--root-dir`.
129    /// Relative paths (like ".") are resolved against the mcp.json's directory.
130    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    /// Track files that have been read to enforce read-before-edit safety
154    files_read: RwLock<HashSet<String>>,
155    tools: T,
156    /// Optional LSP operations (enabled with `.with_lsp()`)
157    lsp: Option<Arc<LspRegistry>>,
158    web_fetcher: WebFetcher,
159    web_searcher: Option<WebSearcher<BraveSearchClient>>,
160    /// Workspace roots (from MCP protocol or CLI args)
161    roots: RwLock<Vec<PathBuf>>,
162    /// Read rules discovered from skill files (activated on file reads)
163    read_rule_state: prompt_rule_matcher::PromptRuleMatcher,
164    /// Configured prompt directories used to build read rules.
165    configured_rules_dirs: Vec<PathBuf>,
166    /// Permission mode controlling user approval for tool calls
167    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    /// Create a new `CodingMcp` with default (local filesystem) tools
190    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
211/// Returns `true` if the command looks destructive (deletes files, force-pushes, etc.).
212///
213/// Uses simple substring matching — conservative by design.
214fn 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    // Check simple substring patterns
234    if PATTERNS.iter().any(|p| command.contains(p)) {
235        return true;
236    }
237
238    // Check redirect operators: only match >/>> that aren't inside quotes or part of =>
239    // Simple heuristic: look for "> " or ">> " not preceded by '='
240    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    /// Create a `CodingMcp` with custom tool implementation
252    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    /// Enable LSP code intelligence for the given project root.
269    ///
270    /// LSP servers for detected project languages are spawned immediately
271    /// in the background, allowing indexing to start right away.
272    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    /// Set workspace roots.
278    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    /// Set prompt directories used for read-triggered rule activation.
286    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    /// Set the workspace root directory from a single path.
294    pub fn with_root_dir(self, root_dir: PathBuf) -> Self {
295        self.with_roots(vec![root_dir])
296    }
297
298    /// Set the permission mode controlling user approval for tool calls.
299    pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
300        self.permission_mode = mode;
301        self
302    }
303
304    /// Get the current workspace root.
305    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    /// Prompt the user for approval via elicitation. Always sends the prompt —
355    /// callers are responsible for deciding when to call this based on `permission_mode`.
356    ///
357    /// Returns `Ok(())` if allowed, or `Err` with either a generic decline
358    /// message or the user's feedback explaining what to do instead.
359    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    /// Ask the user for permission to run a bash command. In `Auto` mode only
392    /// triggers for destructive commands; in `AlwaysAsk` mode triggers always.
393    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    /// Ask the user for permission to write/edit a file. Only triggers in
408    /// `AlwaysAsk` mode; `Auto` and `AlwaysAllow` auto-approve file mutations.
409    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    /// Resolves a required file-path argument against the primary workspace root,
433    /// returning the normalized absolute path as a string.
434    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    /// Reads `args.file_path` (already resolved against the workspace root),
440    /// records it in the read set, and appends any matching read-rule reminders.
441    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    /// Read-before-overwrite safety check: an existing file must have been read
464    /// first, preventing accidental data loss.
465    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    /// Read-before-edit safety check: a file must have been read before editing.
477    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                // Store the background process
639                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                // Return immediate response with shell_id
645                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        // Put handle back if still running
675        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    /// Read a file and track it in the read set (test helper, no MCP context needed).
797    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    /// Write a file with read-before-write safety check (test helper, no MCP context needed).
803    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    /// Edit a file with read-before-edit safety check (test helper, no MCP context needed).
810    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}