Skip to main content

mcp_servers/coding/
mod.rs

1use crate::server_config;
2use aether_project::PromptCatalog;
3use clap::Parser;
4use rmcp::{
5    ErrorData, RoleServer, ServerHandler,
6    handler::server::{
7        router::tool::ToolRouter,
8        tool::{ToolCallContext, parse_json_object, schema_for_output},
9        wrapper::Parameters,
10    },
11    model::{
12        CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, CreateTaskResult, ElicitRequest,
13        ElicitRequestParams, ElicitResult, ElicitationAction, ElicitationSchema, EnumSchema, InputRequest,
14        InputRequests, InputRequiredResult, ProgressNotificationParam, ServerCapabilities, ServerConfig,
15    },
16    service::{ElicitationMode, RequestContext},
17    tool, tool_handler, tool_router,
18};
19use serde::de::DeserializeOwned;
20use std::collections::HashSet;
21use std::fmt::{Debug, Formatter, Write as _};
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex};
24use tokio::fs::try_exists;
25
26pub mod error;
27pub mod prompt_rule_matcher;
28pub mod tools;
29
30use crate::Displayed;
31use crate::background_tasks::{BackgroundTasks, delegate_background_tasks};
32use crate::coding::error::CodingError;
33use crate::coding::prompt_rule_matcher::PromptRuleMatcher;
34use crate::lsp::registry::LspRegistry;
35use crate::lsp::tools::check_errors::{LspDiagnosticsOutput, LspDiagnosticsRequest, execute_lsp_diagnostics};
36use crate::lsp::tools::rename::{LspRenameInput, LspRenameOutput, execute_lsp_rename};
37use crate::lsp::tools::symbol_lookup::{LspSymbolInput, LspSymbolOutput, execute_lsp_symbol};
38use crate::request_context::{CLIENT_CANNOT_ELICIT, parse_response, require_tasks, supports_elicitation};
39use crate::workspace_paths::{resolve_file, resolve_path, resolve_root};
40
41use tools::bash::{BashEnvironment, BashInput, BashOutput, execute_command, validate_args};
42use tools::edit_file::{EditFileArgs, EditFileResponse, edit_file_contents};
43use tools::read_file::{ReadFileArgs, ReadFileResult, read_file_contents};
44use tools::web_fetch::{WebFetchInput, WebFetchOutput, WebFetcher};
45use tools::web_search::{WebSearchInput, WebSearchOutput, WebSearcher};
46use tools::write_file::{WriteFileArgs, WriteFileResponse, write_file_contents};
47use utils::display_meta::{ToolDisplayMeta, ToolResultMeta, basename, truncate};
48use utils::temp_dir::TempDir;
49use utils::tool_result_truncator::ToolResultTruncator;
50
51#[doc = include_str!("../docs/permission_mode.md")]
52#[derive(Debug, Clone, Default, PartialEq, clap::ValueEnum)]
53pub enum PermissionMode {
54    /// Everything auto-executes (current default behavior).
55    #[default]
56    AlwaysAllow,
57    /// File writes auto-execute; destructive bash commands trigger elicitation.
58    Auto,
59    /// Every destructive-annotated tool call triggers elicitation; read-only
60    /// tools are ungated.
61    AlwaysAsk,
62}
63
64/// CLI arguments for `CodingMcp` server
65#[derive(Debug, Clone, Parser)]
66#[command(name = "coding", no_binary_name = true)]
67pub struct CodingMcpArgs {
68    /// Root directory for path resolution and LSP initialization.
69    #[arg(long = "root-dir")]
70    pub root_dir: Option<PathBuf>,
71
72    /// Prompt directories to scan for automatic read-triggered rules.
73    /// Can be specified multiple times: --rules-dir .aether/skills --rules-dir .claude/rules
74    #[arg(long = "rules-dir")]
75    pub rules_dirs: Vec<PathBuf>,
76
77    /// Permission mode controlling user approval for tool calls
78    #[arg(long = "permission-mode", default_value = "always-allow")]
79    pub permission_mode: PermissionMode,
80
81    /// Disable LSP-backed coding tools and daemon connections.
82    #[arg(long = "disable-lsp")]
83    pub disable_lsp: bool,
84}
85
86#[doc = include_str!("../docs/coding_mcp.md")]
87pub struct CodingMcp {
88    tool_router: ToolRouter<Self>,
89    tasks: BackgroundTasks,
90    read_state: Mutex<ReadState>,
91    bash_environment: BashEnvironment,
92    /// Optional LSP operations (enabled with `.with_lsp()`)
93    lsp: Option<Arc<LspRegistry>>,
94    web_fetcher: WebFetcher,
95    web_searcher: Option<WebSearcher>,
96    /// Root directory used for path resolution and tool instructions.
97    root_dir: PathBuf,
98    /// Permission mode controlling user approval for tool calls
99    permission_mode: PermissionMode,
100    spill_dir: Arc<TempDir>,
101    bash_truncator: ToolResultTruncator,
102    web_truncator: ToolResultTruncator,
103}
104
105#[allow(clippy::unused_async_trait_impl)]
106#[tool_handler(router = self.tool_router)]
107impl ServerHandler for CodingMcp {
108    fn get_info(&self) -> ServerConfig {
109        server_config(
110            "coding-mcp",
111            ServerCapabilities::builder().enable_tools().enable_tasks().build(),
112            self.build_instructions(),
113        )
114    }
115
116    async fn call_tool(
117        &self,
118        request: CallToolRequestParams,
119        context: RequestContext<RoleServer>,
120    ) -> Result<CallToolResponse, ErrorData> {
121        let background_args = background_bash_args(&request);
122        if let Some(args) = &background_args {
123            require_tasks(&context)?;
124            validate_args(args).map_err(|error| ErrorData::invalid_params(error.to_string(), None))?;
125        }
126
127        if let Some(prompt) = self.permission_prompt(&request) {
128            let Some(responses) = request.input_responses.clone() else {
129                if !supports_elicitation(&context, ElicitationMode::Form) {
130                    let message = permission_unsupported(&prompt.tool_name);
131                    return Ok(CallToolResult::error(vec![ContentBlock::text(message)]).into());
132                }
133                let requests = InputRequests::from([(
134                    "permission".to_string(),
135                    InputRequest::Elicitation(ElicitRequest::new(decision_form(
136                        &prompt.tool_name,
137                        &prompt.description,
138                    ))),
139                )]);
140                return Ok(InputRequiredResult::from_input_requests(requests).into());
141            };
142
143            let result: ElicitResult = parse_response(&responses, "permission")?;
144            if !permission_granted(&result) {
145                return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
146                    "Operation declined by user: {}",
147                    prompt.tool_name
148                ))])
149                .into());
150            }
151        }
152        if let Some(args) = background_args {
153            return Ok(CallToolResponse::Task(CreateTaskResult::new(self.create_background_bash_task(args))));
154        }
155
156        self.tool_router.call(ToolCallContext::new(self, request, context)).await
157    }
158
159    delegate_background_tasks!(tasks);
160}
161
162#[tool_router]
163impl CodingMcp {
164    pub fn new(root_dir: impl Into<PathBuf>) -> Self {
165        Self {
166            tool_router: LSP_TOOLS
167                .into_iter()
168                .chain([WEB_SEARCH_TOOL])
169                .fold(Self::tool_router(), ToolRouter::with_disabled),
170            tasks: BackgroundTasks::new(),
171            read_state: Mutex::new(ReadState::default()),
172            bash_environment: BashEnvironment::new().with_current_exe_dir_on_path(),
173            lsp: None,
174            web_fetcher: WebFetcher::new(),
175            web_searcher: None,
176            root_dir: root_dir.into(),
177            permission_mode: PermissionMode::AlwaysAllow,
178            spill_dir: Arc::new(TempDir::new()),
179            bash_truncator: ToolResultTruncator { head: 4_000, tail: 12_000 },
180            web_truncator: ToolResultTruncator { head: 24_000, tail: 8_000 },
181        }
182    }
183
184    pub fn from_args(args: CodingMcpArgs, base_dir: &Path) -> Self {
185        let CodingMcpArgs { root_dir, rules_dirs, permission_mode, disable_lsp } = args;
186        let root_dir = resolve_root(base_dir, root_dir);
187        let rules_dirs: Vec<_> = rules_dirs.into_iter().map(|path| resolve_path(&root_dir, path)).collect();
188        let mut server = Self::new(root_dir).with_rules_dirs(&rules_dirs).with_permission_mode(permission_mode);
189        if let Ok(searcher) = WebSearcher::try_new() {
190            server = server.with_web_searcher(searcher);
191        }
192        if disable_lsp { server } else { server.with_lsp() }
193    }
194
195    pub fn with_bash_environment(mut self, environment: BashEnvironment) -> Self {
196        self.bash_environment = environment;
197        self
198    }
199
200    /// Enable LSP code intelligence for the server's root directory.
201    ///
202    /// LSP servers for detected project languages are spawned immediately
203    /// in the background, allowing indexing to start right away.
204    pub fn with_lsp(mut self) -> Self {
205        self.lsp = Some(LspRegistry::new_and_spawn(self.root_dir.clone()));
206        for tool in LSP_TOOLS {
207            self.tool_router.enable_route(tool);
208        }
209        self
210    }
211
212    /// Enable the `web_search` tool, backed by `searcher`.
213    pub fn with_web_searcher(mut self, searcher: WebSearcher) -> Self {
214        self.web_searcher = Some(searcher);
215        self.tool_router.enable_route(WEB_SEARCH_TOOL);
216        self
217    }
218
219    /// Set prompt directories used for read-triggered rule activation.
220    pub fn with_rules_dirs(mut self, rules_dirs: &[PathBuf]) -> Self {
221        let catalog = PromptCatalog::from_dirs(rules_dirs);
222        self.read_state.get_mut().expect("read state lock poisoned").rule_matcher = PromptRuleMatcher::new(catalog);
223        self
224    }
225
226    /// Set the permission mode controlling user approval for tool calls.
227    pub fn with_permission_mode(mut self, mode: PermissionMode) -> Self {
228        self.permission_mode = mode;
229        self
230    }
231
232    #[doc = include_str!("tools/read_file/description.md")]
233    #[tool(
234        annotations(read_only_hint = true, open_world_hint = false),
235        output_schema = schema_for_output::<ReadFileResult>()
236    )]
237    pub async fn read_file(
238        &self,
239        Parameters(mut args): Parameters<ReadFileArgs>,
240        context: RequestContext<RoleServer>,
241    ) -> Result<Displayed<ReadFileResult>, CodingError> {
242        args.file_path = self.resolve_file_arg(&args.file_path)?;
243        notify_preview(&context, ToolDisplayMeta::new("Read file", basename(&args.file_path))).await;
244        self.read_and_track(args).await
245    }
246
247    #[doc = include_str!("tools/write_file/description.md")]
248    #[tool(
249        annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false),
250        output_schema = schema_for_output::<WriteFileResponse>()
251    )]
252    pub async fn write_file(
253        &self,
254        Parameters(mut args): Parameters<WriteFileArgs>,
255        context: RequestContext<RoleServer>,
256    ) -> Result<Displayed<WriteFileResponse>, CodingError> {
257        args.file_path = self.resolve_file_arg(&args.file_path)?;
258        notify_preview(&context, ToolDisplayMeta::new("Write file", basename(&args.file_path))).await;
259
260        self.ensure_read_before_overwrite(&args.file_path).await?;
261
262        let response = write_file_contents(args).await?;
263
264        self.spawn_diagnostic_refresh(&response.output.file_path);
265
266        Ok(response)
267    }
268
269    #[doc = include_str!("tools/edit_file/description.md")]
270    #[tool(
271        annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false),
272        output_schema = schema_for_output::<EditFileResponse>()
273    )]
274    pub async fn edit_file(
275        &self,
276        Parameters(mut args): Parameters<EditFileArgs>,
277        context: RequestContext<RoleServer>,
278    ) -> Result<Displayed<EditFileResponse>, CodingError> {
279        args.file_path = self.resolve_file_arg(&args.file_path)?;
280        notify_preview(&context, ToolDisplayMeta::new("Edit file", basename(&args.file_path))).await;
281
282        self.ensure_read_before_edit(&args.file_path)?;
283
284        let response = edit_file_contents(args).await?;
285
286        self.spawn_diagnostic_refresh(&response.output.file_path);
287
288        Ok(response)
289    }
290
291    #[doc = include_str!("tools/bash/description.md")]
292    #[tool(
293        annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = true),
294        output_schema = schema_for_output::<BashOutput>()
295    )]
296    pub async fn bash(
297        &self,
298        Parameters(args): Parameters<BashInput>,
299        context: RequestContext<RoleServer>,
300    ) -> Result<Displayed<BashOutput>, CodingError> {
301        notify_preview(&context, ToolDisplayMeta::new("Run command", truncate(&args.command, 40))).await;
302
303        run_bash(args, &self.root_dir, &self.bash_environment, self.bash_truncator, &self.spill_dir).await
304    }
305
306    #[doc = include_str!("tools/web_fetch/description.md")]
307    #[tool(
308        annotations(read_only_hint = true, open_world_hint = true),
309        output_schema = schema_for_output::<WebFetchOutput>()
310    )]
311    pub async fn web_fetch(
312        &self,
313        Parameters(args): Parameters<WebFetchInput>,
314        context: RequestContext<RoleServer>,
315    ) -> Result<Displayed<WebFetchOutput>, CodingError> {
316        notify_preview(&context, ToolDisplayMeta::new("Fetch URL", truncate(&args.url, 60))).await;
317        let output = self.web_fetcher.fetch(args).await?;
318        let page = output.title.clone().unwrap_or_else(|| truncate(&output.final_url, 60));
319        let content = self.web_truncator.truncate(output.content, &self.spill_dir, "web-fetch");
320        Ok(Displayed::new(WebFetchOutput { content, ..output }, ToolDisplayMeta::new("Fetch URL", page)))
321    }
322
323    #[doc = include_str!("tools/web_search/description.md")]
324    #[tool(
325        annotations(read_only_hint = true, open_world_hint = true),
326        output_schema = schema_for_output::<WebSearchOutput>()
327    )]
328    pub async fn web_search(
329        &self,
330        Parameters(args): Parameters<WebSearchInput>,
331        context: RequestContext<RoleServer>,
332    ) -> Result<Displayed<WebSearchOutput>, CodingError> {
333        notify_preview(&context, ToolDisplayMeta::new("Web search", format!("'{}'", args.query))).await;
334
335        let searcher = self.web_searcher.as_ref().ok_or(CodingError::NotConfigured("Web search"))?;
336        let output = searcher
337            .search(args, |delay| {
338                notify(&context, format!("Search rate limited; retrying in {} seconds.", delay.as_secs()))
339            })
340            .await?;
341        let summary = format!("'{}' ({} results)", output.query, output.results.len());
342        Ok(Displayed::new(output, ToolDisplayMeta::new("Web search", summary)))
343    }
344
345    #[doc = include_str!("../lsp/tools/symbol_lookup/description.md")]
346    #[tool(
347        annotations(read_only_hint = true, open_world_hint = false),
348        output_schema = schema_for_output::<LspSymbolOutput>()
349    )]
350    pub async fn lsp_symbol(
351        &self,
352        Parameters(input): Parameters<LspSymbolInput>,
353        context: RequestContext<RoleServer>,
354    ) -> Result<Displayed<LspSymbolOutput>, CodingError> {
355        notify_preview(&context, ToolDisplayMeta::new("LSP symbol", &input.symbol)).await;
356        execute_lsp_symbol(input, self.lsp()?).await.map_err(CodingError::from)
357    }
358
359    #[doc = include_str!("../lsp/tools/check_errors/description.md")]
360    #[tool(
361        annotations(read_only_hint = true, open_world_hint = false),
362        output_schema = schema_for_output::<LspDiagnosticsOutput>()
363    )]
364    pub async fn lsp_check_errors(
365        &self,
366        Parameters(request): Parameters<LspDiagnosticsRequest>,
367        context: RequestContext<RoleServer>,
368    ) -> Result<Displayed<LspDiagnosticsOutput>, CodingError> {
369        let preview_value = request.file_path.as_ref().map_or_else(|| "workspace".to_string(), |path| basename(path));
370        notify_preview(&context, ToolDisplayMeta::new("LSP errors", preview_value)).await;
371        execute_lsp_diagnostics(request, self.lsp()?).await.map_err(CodingError::from)
372    }
373
374    #[doc = include_str!("../lsp/tools/rename/description.md")]
375    #[tool(
376        annotations(read_only_hint = false, destructive_hint = true, idempotent_hint = false, open_world_hint = false),
377        output_schema = schema_for_output::<LspRenameOutput>()
378    )]
379    pub async fn lsp_rename(
380        &self,
381        Parameters(input): Parameters<LspRenameInput>,
382        context: RequestContext<RoleServer>,
383    ) -> Result<Displayed<LspRenameOutput>, CodingError> {
384        notify_preview(&context, ToolDisplayMeta::new("LSP rename", &input.symbol)).await;
385        execute_lsp_rename(input, self.lsp()?).await.map_err(CodingError::from)
386    }
387}
388
389#[cfg(feature = "test-helpers")]
390impl CodingMcp {
391    pub fn with_web_fetcher(mut self, web_fetcher: WebFetcher) -> Self {
392        self.web_fetcher = web_fetcher;
393        self
394    }
395}
396
397impl CodingMcp {
398    fn create_background_bash_task(&self, args: BashInput) -> rmcp::model::Task {
399        let cwd = self.root_dir.clone();
400        let environment = self.bash_environment.clone();
401        let spill_dir = Arc::clone(&self.spill_dir);
402        let truncator = self.bash_truncator;
403        let description = args.description.clone().unwrap_or_else(|| truncate(&args.command, 80));
404
405        self.tasks.spawn(description, async move {
406            let result = run_bash(args, &cwd, &environment, truncator, &spill_dir).await.map_err(|e| e.to_string());
407            let result = result.and_then(|output| output.into_result().map_err(|e| e.to_string()));
408            Ok(result.unwrap_or_else(|error| CallToolResult::error(vec![ContentBlock::text(error)])))
409        })
410    }
411
412    fn build_instructions(&self) -> String {
413        let mut base = String::from(
414            r"# Coding MCP Server
415
416File I/O, shell, and optional LSP code intelligence tools for coding workflows.
417
418## Quick Reference
419
420- **Search, symbol lookup by name, file outlines, and directory listings**: run `rg`, `ast-grep`, and other shell commands with `bash`
421- **Read/write/edit** files: `read_file`, `write_file`, `edit_file`
422- **Shell commands**: `bash`
423",
424        );
425
426        if self.lsp.is_some() {
427            base.push_str(
428                r"- **Errors & warnings** (instant check without build): `lsp_check_errors`
429- **Code symbols** (definitions, usages, types): `lsp_symbol`
430- **Rename symbol** (refactor across codebase): `lsp_rename`
431",
432            );
433        }
434
435        format!(
436            r"{}
437
438When using tools that take file paths, always use absolute paths from:
439<workspace-root>{}</workspace-root>",
440            base,
441            self.root_dir.display()
442        )
443    }
444
445    /// Decide whether a tool call needs user approval before it may run.
446    /// Gating is derived from the tool's `destructive_hint` annotation:
447    /// `AlwaysAsk` gates every destructive tool, `Auto` gates only bash
448    /// commands the classifier deems dangerous. Malformed arguments pass
449    /// through ungated so the tool itself reports the parse error.
450    fn permission_prompt(&self, request: &CallToolRequestParams) -> Option<PermissionPrompt> {
451        let name = request.name.as_ref();
452        let destructive = self
453            .tool_router
454            .get(name)
455            .and_then(|tool| tool.annotations.as_ref())
456            .and_then(|annotations| annotations.destructive_hint)
457            .unwrap_or(false);
458        if !destructive || self.permission_mode == PermissionMode::AlwaysAllow {
459            return None;
460        }
461
462        let description = match name {
463            "bash" => parse_arguments::<BashInput>(request)?.command,
464            "write_file" | "edit_file" => {
465                let file_path = parse_arguments::<FilePathArg>(request)?.file_path;
466                self.resolve_file_arg(&file_path).unwrap_or(file_path)
467            }
468            _ => {
469                let args = request.arguments.as_ref().and_then(|a| serde_json::to_string(a).ok());
470                truncate(&args.unwrap_or_default(), 80)
471            }
472        };
473        let gated =
474            self.permission_mode == PermissionMode::AlwaysAsk || (name == "bash" && is_dangerous_cmd(&description));
475        gated.then(|| PermissionPrompt { tool_name: name.to_string(), description })
476    }
477
478    fn spawn_diagnostic_refresh(&self, file_path: &str) {
479        if let Some(lsp) = &self.lsp {
480            let lsp = Arc::clone(lsp);
481            let file_path = file_path.to_string();
482            tokio::spawn(async move {
483                lsp.queue_diagnostic_refresh(&file_path).await;
484            });
485        }
486    }
487
488    /// Resolves a required file-path argument against the root directory,
489    /// returning the normalized absolute path as a string.
490    fn resolve_file_arg(&self, raw: &str) -> Result<String, CodingError> {
491        let path = resolve_file(&self.root_dir, raw)?;
492        Ok(path.to_string_lossy().to_string())
493    }
494
495    fn lsp(&self) -> Result<&LspRegistry, CodingError> {
496        self.lsp.as_deref().ok_or(CodingError::NotConfigured("LSP"))
497    }
498
499    /// Reads `args.file_path` (already resolved against the root directory),
500    /// records it in the read set, and appends any matching read-rule reminders.
501    async fn read_and_track(&self, args: ReadFileArgs) -> Result<Displayed<ReadFileResult>, CodingError> {
502        let file_path = args.file_path.clone();
503        let mut result = read_file_contents(args).await?;
504        let matched = {
505            let mut state = self.read_state.lock().expect("read state lock poisoned");
506            state.files_read.insert(file_path.clone());
507            state.rule_matcher.get_matched_rules(&self.root_dir, &file_path)
508        };
509
510        for rule in &matched {
511            write!(result.content, "\n\n<system-reminder>\n{}\n</system-reminder>", rule.body).unwrap();
512        }
513
514        let mut summary = format!("{}, {} lines", basename(&file_path), result.total_lines);
515        if !matched.is_empty() {
516            let rule_names: Vec<&str> = matched.iter().map(|r| r.name.as_str()).collect();
517            write!(summary, " +rules: {}", rule_names.join(", ")).unwrap();
518        }
519
520        Ok(Displayed::new(result, ToolDisplayMeta::new("Read file", summary)))
521    }
522
523    /// Read-before-overwrite safety check: an existing file must have been read
524    /// first, preventing accidental data loss.
525    async fn ensure_read_before_overwrite(&self, file_path: &str) -> Result<(), CodingError> {
526        let exists = try_exists(file_path)
527            .await
528            .map_err(|source| CodingError::ExistsCheckFailed { path: file_path.to_string(), source })?;
529        if exists && !self.read_state.lock().expect("read state lock poisoned").files_read.contains(file_path) {
530            return Err(CodingError::NotReadBeforeOverwrite(file_path.to_string()));
531        }
532        Ok(())
533    }
534
535    /// Read-before-edit safety check: a file must have been read before editing.
536    fn ensure_read_before_edit(&self, file_path: &str) -> Result<(), CodingError> {
537        if !self.read_state.lock().expect("read state lock poisoned").files_read.contains(file_path) {
538            return Err(CodingError::NotReadBeforeEdit(file_path.to_string()));
539        }
540        Ok(())
541    }
542}
543
544impl Debug for CodingMcp {
545    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
546        formatter
547            .debug_struct("CodingMcp")
548            .field("root_dir", &self.root_dir)
549            .field("permission_mode", &self.permission_mode)
550            .finish_non_exhaustive()
551    }
552}
553
554const LSP_TOOLS: [&str; 3] = ["lsp_check_errors", "lsp_rename", "lsp_symbol"];
555const WEB_SEARCH_TOOL: &str = "web_search";
556
557/// A pending user-approval prompt for a gated tool call.
558struct PermissionPrompt {
559    tool_name: String,
560    description: String,
561}
562
563/// The file-path argument shared by `write_file` and `edit_file`, extracted
564/// for permission descriptions without parsing the full tool input. Must stay
565/// in sync with the serde naming of `WriteFileArgs`/`EditFileArgs`.
566#[derive(serde::Deserialize)]
567struct FilePathArg {
568    #[serde(rename = "filePath", alias = "file_path")]
569    file_path: String,
570}
571
572#[derive(Default)]
573struct ReadState {
574    files_read: HashSet<String>,
575    rule_matcher: PromptRuleMatcher,
576}
577
578async fn notify_preview(context: &RequestContext<RoleServer>, meta: ToolDisplayMeta) {
579    notify(context, serde_json::to_string(&ToolResultMeta::from(meta)).unwrap_or_default()).await;
580}
581
582async fn notify(context: &RequestContext<RoleServer>, message: String) {
583    if let Some(token) = context.meta.get_progress_token() {
584        let _ = context.peer.notify_progress(ProgressNotificationParam::new(token, 0.0).with_message(message)).await;
585    }
586}
587
588fn is_dangerous_cmd(command: &str) -> bool {
589    const PATTERNS: &[&str] = &[
590        "rm ",
591        "rm\t",
592        "rmdir ",
593        "git push",
594        "git reset",
595        "git checkout --",
596        "git clean",
597        "chmod ",
598        "chown ",
599        "kill ",
600        "pkill ",
601        "mv ",
602        "dd ",
603        "--force",
604        "--hard",
605    ];
606
607    if PATTERNS.iter().any(|p| command.contains(p)) {
608        return true;
609    }
610
611    for (i, _) in command.match_indices("> ") {
612        if i == 0 || command.as_bytes()[i - 1] != b'=' {
613            return true;
614        }
615    }
616
617    false
618}
619
620fn permission_granted(result: &ElicitResult) -> bool {
621    result.action == ElicitationAction::Accept
622        && result.content.as_ref().and_then(|c| c.get("decision")).and_then(|v| v.as_str()) == Some("allow")
623}
624
625fn permission_unsupported(tool_name: &str) -> String {
626    format!(
627        "Permission required for {tool_name}, but {CLIENT_CANNOT_ELICIT}. \
628         Use --permission-mode always-allow or an elicitation-capable client."
629    )
630}
631
632fn decision_form(tool_name: &str, description: &str) -> ElicitRequestParams {
633    ElicitRequestParams::FormElicitationParams {
634        meta: None,
635        message: format!("Allow {tool_name}: {description}?"),
636        requested_schema: ElicitationSchema::builder()
637            .required_enum_schema(
638                "decision",
639                EnumSchema::builder(vec!["allow".into(), "deny".into()])
640                    .untitled()
641                    .with_default("deny")
642                    .unwrap()
643                    .build(),
644            )
645            .build()
646            .unwrap(),
647    }
648}
649
650async fn run_bash(
651    args: BashInput,
652    cwd: &Path,
653    environment: &BashEnvironment,
654    truncator: ToolResultTruncator,
655    spill_dir: &TempDir,
656) -> Result<Displayed<BashOutput>, CodingError> {
657    let command = truncate(&args.command, 40);
658    let output = execute_command(args, Some(cwd), environment).await?;
659    let timed_out = if output.killed { ", timed out" } else { "" };
660    let meta = ToolDisplayMeta::new("Ran", format!("{command} (exit {}{timed_out})", output.exit_code));
661    Ok(Displayed::new(BashOutput { output: truncator.truncate(output.output, spill_dir, "bash"), ..output }, meta))
662}
663
664fn background_bash_args(request: &CallToolRequestParams) -> Option<BashInput> {
665    if request.name.as_ref() != "bash" {
666        return None;
667    }
668    parse_arguments::<BashInput>(request).filter(|args| args.run_in_background == Some(true))
669}
670
671fn parse_arguments<T: DeserializeOwned>(request: &CallToolRequestParams) -> Option<T> {
672    request.arguments.clone().and_then(|arguments| parse_json_object(arguments).ok())
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    #[test]
680    fn args_parse_permission_mode_lsp_and_rules_dirs() {
681        let cases: [(&[&str], PermissionMode, bool); 5] = [
682            (&[], PermissionMode::AlwaysAllow, false),
683            (&["--permission-mode", "always-allow"], PermissionMode::AlwaysAllow, false),
684            (&["--permission-mode", "auto"], PermissionMode::Auto, false),
685            (&["--permission-mode", "always-ask"], PermissionMode::AlwaysAsk, false),
686            (&["--disable-lsp"], PermissionMode::AlwaysAllow, true),
687        ];
688        for (argv, permission_mode, disable_lsp) in cases {
689            let args = CodingMcpArgs::try_parse_from(argv).unwrap();
690            assert_eq!((args.permission_mode, args.disable_lsp), (permission_mode, disable_lsp), "{argv:?}");
691        }
692
693        assert!(CodingMcpArgs::try_parse_from(["--permission-mode", "yolo"]).is_err());
694        let args = CodingMcpArgs::try_parse_from(["--rules-dir", ".aether/skills", "--rules-dir", ".claude/rules"]);
695        assert_eq!(args.unwrap().rules_dirs, [PathBuf::from(".aether/skills"), PathBuf::from(".claude/rules")]);
696    }
697}