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
66#[derive(Default)]
67pub struct ToolManager {
68 tools: HashMap<String, ToolDefinition>,
69}
70
71impl ToolManager {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub async fn load_from_file(&mut self, path: &Path) -> Result<()> {
78 info!("Loading tools from: {}", path.display());
79
80 let content = tokio::fs::read_to_string(path)
81 .await
82 .context("Failed to read tools file")?;
83
84 let config: ToolsConfig = serde_yaml::from_str(&content).context("Failed to parse YAML")?;
86
87 for include in &config.include {
89 let include_path = self.resolve_include_path(path, include)?;
90 info!("Including tools from: {}", include_path.display());
91
92 Box::pin(self.load_from_file(&include_path)).await?;
94 }
95
96 for tool in config.tools {
98 info!("Loaded tool: {}", tool.name);
99 self.tools.insert(tool.name.clone(), tool);
100 }
101
102 Ok(())
103 }
104
105 fn resolve_include_path(&self, base_path: &Path, include: &str) -> Result<PathBuf> {
106 let base_dir = base_path
107 .parent()
108 .ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory"))?;
109
110 let include_path = if include.starts_with('/') {
112 PathBuf::from(include)
113 } else {
114 match include.starts_with("~/") {
115 true => {
116 if let Some(home) = directories::UserDirs::new() {
117 home.home_dir().join(&include[2..])
118 } else {
119 return Err(anyhow::anyhow!("Cannot resolve home directory"));
120 }
121 }
122 false => {
123 base_dir.join(include)
125 }
126 }
127 };
128
129 if !include_path.exists() {
130 return Err(anyhow::anyhow!(
131 "Include file not found: {}",
132 include_path.display()
133 ));
134 }
135
136 Ok(include_path)
137 }
138
139
140 pub async fn load_with_precedence(&mut self, cli_override: Option<String>) -> Result<()> {
141 if let Some(tools_file) = cli_override {
144 info!("Loading tools from command-line override: {}", tools_file);
145 return self.load_from_file(Path::new(&tools_file)).await;
146 }
147
148 if let Ok(tools_file) = std::env::var("GAMECODE_TOOLS_FILE") {
150 info!("Loading tools from GAMECODE_TOOLS_FILE: {}", tools_file);
151 return self.load_from_file(Path::new(&tools_file)).await;
152 }
153
154 let local_tools = PathBuf::from("./tools.yaml");
156 if local_tools.exists() {
157 info!("Loading tools from local tools.yaml");
158 return self.load_from_file(&local_tools).await;
159 }
160
161 if let Ok(mode) = self.detect_project_type() {
163 info!("Auto-detected {} project", mode);
164 if self.load_auto_detected_tools(&mode).await.is_ok() {
165 return Ok(());
166 }
167 }
168
169 if let Some(home) = directories::UserDirs::new() {
171 let config_tools = home.home_dir()
172 .join(".config/gamecode-mcp/tools.yaml");
173 if config_tools.exists() {
174 info!("Loading tools from config directory");
175 return self.load_from_file(&config_tools).await;
176 }
177 }
178
179 Err(anyhow::anyhow!("No tools configuration found. Create tools.yaml or use --tools-file"))
180 }
181
182 fn detect_project_type(&self) -> Result<String> {
183 let detections = vec![
184 ("Cargo.toml", "rust"),
185 ("package.json", "javascript"),
186 ("requirements.txt", "python"),
187 ("go.mod", "go"),
188 ("pom.xml", "java"),
189 ("build.gradle", "java"),
190 ("Gemfile", "ruby"),
191 ];
192
193 for (file, mode) in detections {
194 if PathBuf::from(file).exists() {
195 return Ok(mode.to_string());
196 }
197 }
198
199 Err(anyhow::anyhow!("No project type detected"))
200 }
201
202 async fn load_auto_detected_tools(&mut self, mode: &str) -> Result<()> {
203 let lang_file = format!("tools/languages/{}.yaml", mode);
205 if PathBuf::from(&lang_file).exists() {
206 self.load_from_file(Path::new(&lang_file)).await?;
207 }
208
209 if PathBuf::from("tools/core.yaml").exists() {
211 self.load_from_file(Path::new("tools/core.yaml")).await?;
212 }
213
214 if PathBuf::from(".git").exists() && PathBuf::from("tools/git.yaml").exists() {
216 self.load_from_file(Path::new("tools/git.yaml")).await?;
217 }
218
219 Ok(())
220 }
221
222 pub fn get_mcp_tools(&self) -> Vec<Tool> {
224 self.tools
225 .values()
226 .map(|def| {
227 let mut properties = serde_json::Map::new();
228 let mut required = Vec::new();
229
230 for arg in &def.args {
232 let arg_schema = match arg.arg_type.as_str() {
233 "string" => json!({
234 "type": "string",
235 "description": arg.description
236 }),
237 "number" => json!({
238 "type": "number",
239 "description": arg.description
240 }),
241 "boolean" => json!({
242 "type": "boolean",
243 "description": arg.description
244 }),
245 "array" => json!({
246 "type": "array",
247 "description": arg.description
248 }),
249 _ => json!({
250 "type": "string",
251 "description": arg.description
252 }),
253 };
254
255 properties.insert(arg.name.clone(), arg_schema);
256
257 if arg.required {
258 required.push(json!(arg.name));
259 }
260 }
261
262 let schema = json!({
263 "type": "object",
264 "properties": properties,
265 "required": required
266 });
267
268 Tool {
269 name: def.name.clone(),
270 description: def.description.clone(),
271 input_schema: schema,
272 }
273 })
274 .collect()
275 }
276
277 pub async fn execute_tool(&self, name: &str, args: Value, injected_values: &HashMap<String, String>) -> Result<Value> {
279 let tool = self
280 .tools
281 .get(name)
282 .ok_or_else(|| anyhow::anyhow!("Tool '{}' not found", name))?;
283
284 if let Some(handler) = &tool.internal_handler {
286 return self.execute_internal_handler(handler, &args, injected_values).await;
287 }
288
289 if tool.command.is_empty() || tool.command == "internal" {
291 return Err(anyhow::anyhow!("Tool '{}' has no command", name));
292 }
293
294 let mut cmd = Command::new(&tool.command);
295
296 for (key, value) in injected_values {
298 cmd.env(format!("GAMECODE_{}", key.to_uppercase()), value);
299 }
300
301 for flag in &tool.static_flags {
303 cmd.arg(flag);
304 }
305
306 if let Some(obj) = args.as_object() {
308 for arg_def in &tool.args {
309 if let Some(value) = obj.get(&arg_def.name) {
310 if tool.validation.validate_args {
312 validation::validate_typed_value(value, &arg_def.arg_type)?;
313 }
314
315 if arg_def.is_path && tool.validation.validate_paths {
317 if let Some(path_str) = value.as_str() {
318 validation::validate_path(path_str, tool.validation.allow_absolute_paths)?;
319 }
320 }
321
322 let arg_value = value.to_string().trim_matches('"').to_string();
323
324 if let Some(cli_flag) = &arg_def.cli_flag {
325 cmd.arg(cli_flag);
326 cmd.arg(&arg_value);
327 } else {
328 cmd.arg(&arg_value);
330 }
331 }
332 }
333 }
334
335 debug!("Executing command: {:?}", cmd);
336
337 let output = cmd
338 .stdout(Stdio::piped())
339 .stderr(Stdio::piped())
340 .output()
341 .await
342 .context("Failed to execute command")?;
343
344 if output.status.success() {
345 let stdout = String::from_utf8_lossy(&output.stdout);
346
347 if let Ok(json_value) = serde_json::from_str::<Value>(&stdout) {
349 Ok(json_value)
350 } else {
351 Ok(json!({
352 "output": stdout.trim(),
353 "status": "success"
354 }))
355 }
356 } else {
357 let stderr = String::from_utf8_lossy(&output.stderr);
358 Err(anyhow::anyhow!("Command failed: {}", stderr))
359 }
360 }
361
362 async fn execute_internal_handler(&self, handler: &str, args: &Value, _injected_values: &HashMap<String, String>) -> Result<Value> {
364 match handler {
365 "add" => {
366 let a = args
367 .get("a")
368 .and_then(|v| v.as_f64())
369 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
370 let b = args
371 .get("b")
372 .and_then(|v| v.as_f64())
373 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
374 Ok(json!({
375 "result": a + b,
376 "operation": "addition"
377 }))
378 }
379 "multiply" => {
380 let a = args
381 .get("a")
382 .and_then(|v| v.as_f64())
383 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
384 let b = args
385 .get("b")
386 .and_then(|v| v.as_f64())
387 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
388 Ok(json!({
389 "result": a * b,
390 "operation": "multiplication"
391 }))
392 }
393 "list_files" => {
394 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
395
396 let mut files = Vec::new();
397 let mut entries = tokio::fs::read_dir(path).await?;
398
399 while let Some(entry) = entries.next_entry().await? {
400 let metadata = entry.metadata().await?;
401 files.push(json!({
402 "name": entry.file_name().to_string_lossy(),
403 "is_dir": metadata.is_dir(),
404 "size": metadata.len()
405 }));
406 }
407
408 Ok(json!({
409 "path": path,
410 "files": files
411 }))
412 }
413 "write_file" => {
414 let path = args
415 .get("path")
416 .and_then(|v| v.as_str())
417 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'path'"))?;
418 let content = args
419 .get("content")
420 .and_then(|v| v.as_str())
421 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
422
423 tokio::fs::write(path, content).await?;
424
425 Ok(json!({
426 "status": "success",
427 "path": path,
428 "bytes_written": content.len()
429 }))
430 }
431 "create_graphviz_diagram" => {
432 let filename = args
433 .get("filename")
434 .and_then(|v| v.as_str())
435 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'filename'"))?;
436 let format = args
437 .get("format")
438 .and_then(|v| v.as_str())
439 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'format'"))?;
440 let content = args
441 .get("content")
442 .and_then(|v| v.as_str())
443 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
444
445 let dot_file = format!("{}.dot", filename);
447 tokio::fs::write(&dot_file, content).await?;
448
449 let output_file = format!("{}.{}", filename, format);
451 let output = tokio::process::Command::new("dot")
452 .arg(format!("-T{}", format))
453 .arg(&dot_file)
454 .arg("-o")
455 .arg(&output_file)
456 .output()
457 .await?;
458
459 if !output.status.success() {
460 let stderr = String::from_utf8_lossy(&output.stderr);
461 return Err(anyhow::anyhow!("GraphViz error: {}", stderr));
462 }
463
464 Ok(json!({
465 "status": "success",
466 "source_file": dot_file,
467 "output_file": output_file,
468 "format": format
469 }))
470 }
471 "create_plantuml_diagram" => {
472 let filename = args
473 .get("filename")
474 .and_then(|v| v.as_str())
475 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'filename'"))?;
476 let format = args
477 .get("format")
478 .and_then(|v| v.as_str())
479 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'format'"))?;
480 let content = args
481 .get("content")
482 .and_then(|v| v.as_str())
483 .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
484
485 let puml_file = format!("{}.puml", filename);
487 tokio::fs::write(&puml_file, content).await?;
488
489 let output = tokio::process::Command::new("plantuml")
491 .arg(format!("-t{}", format))
492 .arg(&puml_file)
493 .output()
494 .await?;
495
496 if !output.status.success() {
497 let stderr = String::from_utf8_lossy(&output.stderr);
498 return Err(anyhow::anyhow!("PlantUML error: {}", stderr));
499 }
500
501 let output_file = format!("{}.{}", filename, format);
503
504 Ok(json!({
505 "status": "success",
506 "source_file": puml_file,
507 "output_file": output_file,
508 "format": format
509 }))
510 }
511 _ => Err(anyhow::anyhow!("Unknown internal handler: {}", handler)),
512 }
513 }
514}