1use anyhow::{Context, Result};
5use serde::Deserialize;
6use serde_json::{json, Value};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use tokio::process::Command;
11use tracing::{debug, info};
12
13use crate::protocol::Tool;
14use crate::validation;
15
16#[derive(Debug, Deserialize)]
18pub struct ToolsConfig {
19 #[serde(default)]
20 pub include: Vec<String>,
21 #[serde(default)]
22 pub tools: Vec<ToolDefinition>,
23}
24
25#[derive(Debug, Clone, Deserialize)]
26pub struct ToolDefinition {
27 pub name: String,
28 pub description: String,
29 #[serde(default)]
30 pub command: String,
31 #[serde(default)]
32 pub args: Vec<ArgDefinition>,
33 #[serde(default)]
34 pub static_flags: Vec<String>,
35 pub internal_handler: Option<String>,
36 #[allow(dead_code)]
37 pub example_output: Option<Value>,
38 #[serde(default)]
39 pub validation: ValidationConfig,
40}
41
42#[derive(Debug, Clone, Deserialize, Default)]
43pub struct ValidationConfig {
44 #[serde(default)]
45 pub validate_paths: bool,
46 #[serde(default)]
47 pub allow_absolute_paths: bool,
48 #[serde(default)]
49 pub validate_args: bool,
50}
51
52#[derive(Debug, Clone, Deserialize)]
53pub struct ArgDefinition {
54 pub name: String,
55 pub description: String,
56 pub required: bool,
57 #[serde(rename = "type")]
58 pub arg_type: String,
59 pub cli_flag: Option<String>,
60 #[allow(dead_code)]
61 pub default: Option<String>,
62 #[serde(default)]
63 pub is_path: bool, }
65
66pub struct ToolManager {
67 tools: HashMap<String, ToolDefinition>,
68}
69
70impl ToolManager {
71 pub fn new() -> Self {
72 Self {
73 tools: HashMap::new(),
74 }
75 }
76
77 #[cfg(test)]
78 pub fn new_for_testing() -> Self {
79 Self::new()
81 }
82
83 pub async fn load_from_file(&mut self, path: &Path) -> Result<()> {
85 info!("Loading tools from: {}", path.display());
86
87 let content = tokio::fs::read_to_string(path)
88 .await
89 .context("Failed to read tools file")?;
90
91 let config: ToolsConfig = serde_yaml::from_str(&content).context("Failed to parse YAML")?;
93
94 for include in &config.include {
96 let include_path = self.resolve_include_path(path, include)?;
97 info!("Including tools from: {}", include_path.display());
98
99 Box::pin(self.load_from_file(&include_path)).await?;
101 }
102
103 for tool in config.tools {
105 info!("Loaded tool: {}", tool.name);
106 self.tools.insert(tool.name.clone(), tool);
107 }
108
109 Ok(())
110 }
111
112 fn resolve_include_path(&self, base_path: &Path, include: &str) -> Result<PathBuf> {
113 let base_dir = base_path
114 .parent()
115 .ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory"))?;
116
117 let include_path = if include.starts_with('/') {
119 PathBuf::from(include)
120 } else {
121 match include.starts_with("~/") {
122 true => {
123 if let Some(home) = directories::UserDirs::new() {
124 home.home_dir().join(&include[2..])
125 } else {
126 return Err(anyhow::anyhow!("Cannot resolve home directory"));
127 }
128 }
129 false => {
130 base_dir.join(include)
132 }
133 }
134 };
135
136 if !include_path.exists() {
137 return Err(anyhow::anyhow!(
138 "Include file not found: {}",
139 include_path.display()
140 ));
141 }
142
143 Ok(include_path)
144 }
145
146 pub async fn load_from_default_locations(&mut self) -> Result<()> {
147 let paths = vec![
149 PathBuf::from("./tools.yaml"),
150 PathBuf::from("~/.config/gamecode-mcp/tools.yaml"),
151 ];
152
153 if let Ok(tools_file) = std::env::var("GAMECODE_TOOLS_FILE") {
154 return self.load_from_file(Path::new(&tools_file)).await;
155 }
156
157 for path in paths {
158 let expanded = if path.starts_with("~") {
159 if let Some(home) = directories::UserDirs::new() {
160 home.home_dir().join(path.strip_prefix("~").unwrap())
161 } else {
162 continue;
163 }
164 } else {
165 path
166 };
167
168 if expanded.exists() {
169 return self.load_from_file(&expanded).await;
170 }
171 }
172
173 Err(anyhow::anyhow!("No tools.yaml file found"))
174 }
175
176 pub async fn load_mode(&mut self, mode: &str) -> Result<()> {
177 self.tools.clear();
179
180 let mode_file = format!("tools/profiles/{}.yaml", mode);
182 let mode_path = PathBuf::from(&mode_file);
183
184 if mode_path.exists() {
185 self.load_from_file(&mode_path).await
186 } else {
187 if let Some(home) = directories::UserDirs::new() {
189 let config_path = home
190 .home_dir()
191 .join(".config/gamecode-mcp")
192 .join(&mode_file);
193 if config_path.exists() {
194 return self.load_from_file(&config_path).await;
195 }
196 }
197
198 Err(anyhow::anyhow!("Mode configuration '{}' not found", mode))
199 }
200 }
201
202 pub async fn load_with_precedence(&mut self, cli_override: Option<String>) -> Result<()> {
203 if let Some(tools_file) = cli_override {
206 info!("Loading tools from command-line override: {}", tools_file);
207 return self.load_from_file(Path::new(&tools_file)).await;
208 }
209
210 if let Ok(tools_file) = std::env::var("GAMECODE_TOOLS_FILE") {
212 info!("Loading tools from GAMECODE_TOOLS_FILE: {}", tools_file);
213 return self.load_from_file(Path::new(&tools_file)).await;
214 }
215
216 let local_tools = PathBuf::from("./tools.yaml");
218 if local_tools.exists() {
219 info!("Loading tools from local tools.yaml");
220 return self.load_from_file(&local_tools).await;
221 }
222
223 if let Ok(mode) = self.detect_project_type() {
225 info!("Auto-detected {} project", mode);
226 if let Ok(_) = self.load_auto_detected_tools(&mode).await {
227 return Ok(());
228 }
229 }
230
231 if let Some(home) = directories::UserDirs::new() {
233 let config_tools = home.home_dir()
234 .join(".config/gamecode-mcp/tools.yaml");
235 if config_tools.exists() {
236 info!("Loading tools from config directory");
237 return self.load_from_file(&config_tools).await;
238 }
239 }
240
241 Err(anyhow::anyhow!("No tools configuration found. Create tools.yaml or use --tools-file"))
242 }
243
244 fn detect_project_type(&self) -> Result<String> {
245 let detections = vec![
246 ("Cargo.toml", "rust"),
247 ("package.json", "javascript"),
248 ("requirements.txt", "python"),
249 ("go.mod", "go"),
250 ("pom.xml", "java"),
251 ("build.gradle", "java"),
252 ("Gemfile", "ruby"),
253 ];
254
255 for (file, mode) in detections {
256 if PathBuf::from(file).exists() {
257 return Ok(mode.to_string());
258 }
259 }
260
261 Err(anyhow::anyhow!("No project type detected"))
262 }
263
264 async fn load_auto_detected_tools(&mut self, mode: &str) -> Result<()> {
265 let lang_file = format!("tools/languages/{}.yaml", mode);
267 if PathBuf::from(&lang_file).exists() {
268 self.load_from_file(Path::new(&lang_file)).await?;
269 }
270
271 if PathBuf::from("tools/core.yaml").exists() {
273 self.load_from_file(Path::new("tools/core.yaml")).await?;
274 }
275
276 if PathBuf::from(".git").exists() && PathBuf::from("tools/git.yaml").exists() {
278 self.load_from_file(Path::new("tools/git.yaml")).await?;
279 }
280
281 Ok(())
282 }
283
284 pub fn get_mcp_tools(&self) -> Vec<Tool> {
286 self.tools
287 .values()
288 .map(|def| {
289 let mut properties = serde_json::Map::new();
290 let mut required = Vec::new();
291
292 for arg in &def.args {
294 let arg_schema = match arg.arg_type.as_str() {
295 "string" => json!({
296 "type": "string",
297 "description": arg.description
298 }),
299 "number" => json!({
300 "type": "number",
301 "description": arg.description
302 }),
303 "boolean" => json!({
304 "type": "boolean",
305 "description": arg.description
306 }),
307 "array" => json!({
308 "type": "array",
309 "description": arg.description
310 }),
311 _ => json!({
312 "type": "string",
313 "description": arg.description
314 }),
315 };
316
317 properties.insert(arg.name.clone(), arg_schema);
318
319 if arg.required {
320 required.push(json!(arg.name));
321 }
322 }
323
324 let schema = json!({
325 "type": "object",
326 "properties": properties,
327 "required": required
328 });
329
330 Tool {
331 name: def.name.clone(),
332 description: def.description.clone(),
333 input_schema: schema,
334 }
335 })
336 .collect()
337 }
338
339 pub async fn execute_tool(&self, name: &str, args: Value) -> Result<Value> {
341 let tool = self
342 .tools
343 .get(name)
344 .ok_or_else(|| anyhow::anyhow!("Tool '{}' not found", name))?;
345
346 if let Some(handler) = &tool.internal_handler {
348 return self.execute_internal_handler(handler, &args).await;
349 }
350
351 if tool.command.is_empty() || tool.command == "internal" {
353 return Err(anyhow::anyhow!("Tool '{}' has no command", name));
354 }
355
356 let mut cmd = Command::new(&tool.command);
357
358 for flag in &tool.static_flags {
360 cmd.arg(flag);
361 }
362
363 if let Some(obj) = args.as_object() {
365 for arg_def in &tool.args {
366 if let Some(value) = obj.get(&arg_def.name) {
367 if tool.validation.validate_args {
369 validation::validate_typed_value(value, &arg_def.arg_type)?;
370 }
371
372 if arg_def.is_path && tool.validation.validate_paths {
374 if let Some(path_str) = value.as_str() {
375 validation::validate_path(path_str, tool.validation.allow_absolute_paths)?;
376 }
377 }
378
379 let arg_value = value.to_string().trim_matches('"').to_string();
380
381 if let Some(cli_flag) = &arg_def.cli_flag {
382 cmd.arg(cli_flag);
383 cmd.arg(&arg_value);
384 } else {
385 cmd.arg(&arg_value);
387 }
388 }
389 }
390 }
391
392 debug!("Executing command: {:?}", cmd);
393
394 let output = cmd
395 .stdout(Stdio::piped())
396 .stderr(Stdio::piped())
397 .output()
398 .await
399 .context("Failed to execute command")?;
400
401 if output.status.success() {
402 let stdout = String::from_utf8_lossy(&output.stdout);
403
404 if let Ok(json_value) = serde_json::from_str::<Value>(&stdout) {
406 Ok(json_value)
407 } else {
408 Ok(json!({
409 "output": stdout.trim(),
410 "status": "success"
411 }))
412 }
413 } else {
414 let stderr = String::from_utf8_lossy(&output.stderr);
415 Err(anyhow::anyhow!("Command failed: {}", stderr))
416 }
417 }
418
419 async fn execute_internal_handler(&self, handler: &str, args: &Value) -> Result<Value> {
421 match handler {
422 "add" => {
423 let a = args
424 .get("a")
425 .and_then(|v| v.as_f64())
426 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
427 let b = args
428 .get("b")
429 .and_then(|v| v.as_f64())
430 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
431 Ok(json!({
432 "result": a + b,
433 "operation": "addition"
434 }))
435 }
436 "multiply" => {
437 let a = args
438 .get("a")
439 .and_then(|v| v.as_f64())
440 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
441 let b = args
442 .get("b")
443 .and_then(|v| v.as_f64())
444 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
445 Ok(json!({
446 "result": a * b,
447 "operation": "multiplication"
448 }))
449 }
450 "list_files" => {
451 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
452
453 let mut files = Vec::new();
454 let mut entries = tokio::fs::read_dir(path).await?;
455
456 while let Some(entry) = entries.next_entry().await? {
457 let metadata = entry.metadata().await?;
458 files.push(json!({
459 "name": entry.file_name().to_string_lossy(),
460 "is_dir": metadata.is_dir(),
461 "size": metadata.len()
462 }));
463 }
464
465 Ok(json!({
466 "path": path,
467 "files": files
468 }))
469 }
470 "write_file" => {
471 let path = args
472 .get("path")
473 .and_then(|v| v.as_str())
474 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'path'"))?;
475 let content = args
476 .get("content")
477 .and_then(|v| v.as_str())
478 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
479
480 tokio::fs::write(path, content).await?;
481
482 Ok(json!({
483 "status": "success",
484 "path": path,
485 "bytes_written": content.len()
486 }))
487 }
488 "create_graphviz_diagram" => {
489 let filename = args
490 .get("filename")
491 .and_then(|v| v.as_str())
492 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'filename'"))?;
493 let format = args
494 .get("format")
495 .and_then(|v| v.as_str())
496 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'format'"))?;
497 let content = args
498 .get("content")
499 .and_then(|v| v.as_str())
500 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
501
502 let dot_file = format!("{}.dot", filename);
504 tokio::fs::write(&dot_file, content).await?;
505
506 let output_file = format!("{}.{}", filename, format);
508 let output = tokio::process::Command::new("dot")
509 .arg(format!("-T{}", format))
510 .arg(&dot_file)
511 .arg("-o")
512 .arg(&output_file)
513 .output()
514 .await?;
515
516 if !output.status.success() {
517 let stderr = String::from_utf8_lossy(&output.stderr);
518 return Err(anyhow::anyhow!("GraphViz error: {}", stderr));
519 }
520
521 Ok(json!({
522 "status": "success",
523 "source_file": dot_file,
524 "output_file": output_file,
525 "format": format
526 }))
527 }
528 "create_plantuml_diagram" => {
529 let filename = args
530 .get("filename")
531 .and_then(|v| v.as_str())
532 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'filename'"))?;
533 let format = args
534 .get("format")
535 .and_then(|v| v.as_str())
536 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'format'"))?;
537 let content = args
538 .get("content")
539 .and_then(|v| v.as_str())
540 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
541
542 let puml_file = format!("{}.puml", filename);
544 tokio::fs::write(&puml_file, content).await?;
545
546 let output = tokio::process::Command::new("plantuml")
548 .arg(format!("-t{}", format))
549 .arg(&puml_file)
550 .output()
551 .await?;
552
553 if !output.status.success() {
554 let stderr = String::from_utf8_lossy(&output.stderr);
555 return Err(anyhow::anyhow!("PlantUML error: {}", stderr));
556 }
557
558 let output_file = format!("{}.{}", filename, format);
560
561 Ok(json!({
562 "status": "success",
563 "source_file": puml_file,
564 "output_file": output_file,
565 "format": format
566 }))
567 }
568 _ => Err(anyhow::anyhow!("Unknown internal handler: {}", handler)),
569 }
570 }
571}