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 #[default]
56 AlwaysAllow,
57 Auto,
59 AlwaysAsk,
62}
63
64#[derive(Debug, Clone, Parser)]
66#[command(name = "coding", no_binary_name = true)]
67pub struct CodingMcpArgs {
68 #[arg(long = "root-dir")]
70 pub root_dir: Option<PathBuf>,
71
72 #[arg(long = "rules-dir")]
75 pub rules_dirs: Vec<PathBuf>,
76
77 #[arg(long = "permission-mode", default_value = "always-allow")]
79 pub permission_mode: PermissionMode,
80
81 #[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 lsp: Option<Arc<LspRegistry>>,
94 web_fetcher: WebFetcher,
95 web_searcher: Option<WebSearcher>,
96 root_dir: PathBuf,
98 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 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 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 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 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 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 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 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 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 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
557struct PermissionPrompt {
559 tool_name: String,
560 description: String,
561}
562
563#[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}