gamecode_mcp2/
tools.rs

1// Tool execution is the critical security boundary.
2// Every tool must be explicitly configured - no implicit capabilities.
3
4use 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// Tools config - what tools exist is controlled by YAML, not code
17#[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,  // Mark arguments that are file paths
64}
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    // Explicit tool loading - admin controls what tools are available
77    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        // YAML parsing is the only text processing we can't avoid
85        let config: ToolsConfig = serde_yaml::from_str(&content).context("Failed to parse YAML")?;
86
87        // Process includes first
88        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            // Recursively load included files
93            Box::pin(self.load_from_file(&include_path)).await?;
94        }
95
96        // Then load tools from this file
97        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        // Support both relative and absolute paths
111        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                    // Relative path
124                    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        // Clear precedence order:
142        // 1. Command-line flag (--tools-file)
143        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        // 2. Environment variable
149        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        // 3. Local tools.yaml in current directory
155        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        // 4. Auto-detection (only if no local tools.yaml)
162        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        // 5. Config directory fallback
170        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        // Try to load language-specific tools
204        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        // Always load core tools as well
210        if PathBuf::from("tools/core.yaml").exists() {
211            self.load_from_file(Path::new("tools/core.yaml")).await?;
212        }
213        
214        // Load git tools if .git exists
215        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    // Convert to MCP schema - LLM sees exactly this, nothing hidden
223    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                // Build JSON schema from arg definitions
231                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    // Tool execution - the critical security boundary
278    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        // Internal handlers are hardcoded - no dynamic code execution
285        if let Some(handler) = &tool.internal_handler {
286            return self.execute_internal_handler(handler, &args, injected_values).await;
287        }
288
289        // External commands - only what's explicitly configured
290        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        // Set injected values as environment variables for the command
297        for (key, value) in injected_values {
298            cmd.env(format!("GAMECODE_{}", key.to_uppercase()), value);
299        }
300
301        // Add static flags
302        for flag in &tool.static_flags {
303            cmd.arg(flag);
304        }
305
306        // Argument construction - no shell interpretation, direct args only
307        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                    // Optional validation
311                    if tool.validation.validate_args {
312                        validation::validate_typed_value(value, &arg_def.arg_type)?;
313                    }
314                    
315                    // Path validation if marked as path
316                    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                        // Positional argument
329                        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            // Try to parse as JSON first
348            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    // Internal handlers - hardcoded, no dynamic evaluation
363    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                // Save DOT source file
446                let dot_file = format!("{}.dot", filename);
447                tokio::fs::write(&dot_file, content).await?;
448
449                // Generate diagram using GraphViz
450                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                // Save PlantUML source file
486                let puml_file = format!("{}.puml", filename);
487                tokio::fs::write(&puml_file, content).await?;
488
489                // Generate diagram using PlantUML
490                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                // PlantUML generates output with same base name
502                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}