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
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        // For tests, just create empty manager that tests can load explicitly
80        Self::new()
81    }
82
83    // Explicit tool loading - admin controls what tools are available
84    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        // YAML parsing is the only text processing we can't avoid
92        let config: ToolsConfig = serde_yaml::from_str(&content).context("Failed to parse YAML")?;
93
94        // Process includes first
95        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            // Recursively load included files
100            Box::pin(self.load_from_file(&include_path)).await?;
101        }
102
103        // Then load tools from this file
104        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        // Support both relative and absolute paths
118        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                    // Relative path
131                    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        // Check for tools.yaml in various locations
148        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        // Clear existing tools when switching modes
178        self.tools.clear();
179
180        // Load the mode-specific configuration
181        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            // Try in config directory
188            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        // Clear precedence order:
204        // 1. Command-line flag (--tools-file)
205        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        // 2. Environment variable
211        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        // 3. Local tools.yaml in current directory
217        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        // 4. Auto-detection (only if no local tools.yaml)
224        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        // 5. Config directory fallback
232        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        // Try to load language-specific tools
266        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        // Always load core tools as well
272        if PathBuf::from("tools/core.yaml").exists() {
273            self.load_from_file(Path::new("tools/core.yaml")).await?;
274        }
275        
276        // Load git tools if .git exists
277        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    // Convert to MCP schema - LLM sees exactly this, nothing hidden
285    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                // Build JSON schema from arg definitions
293                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    // Tool execution - the critical security boundary
340    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        // Internal handlers are hardcoded - no dynamic code execution
347        if let Some(handler) = &tool.internal_handler {
348            return self.execute_internal_handler(handler, &args).await;
349        }
350
351        // External commands - only what's explicitly configured
352        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        // Add static flags
359        for flag in &tool.static_flags {
360            cmd.arg(flag);
361        }
362
363        // Argument construction - no shell interpretation, direct args only
364        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                    // Optional validation
368                    if tool.validation.validate_args {
369                        validation::validate_typed_value(value, &arg_def.arg_type)?;
370                    }
371                    
372                    // Path validation if marked as path
373                    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                        // Positional argument
386                        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            // Try to parse as JSON first
405            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    // Internal handlers - hardcoded, no dynamic evaluation
420    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                // Save DOT source file
503                let dot_file = format!("{}.dot", filename);
504                tokio::fs::write(&dot_file, content).await?;
505
506                // Generate diagram using GraphViz
507                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                // Save PlantUML source file
543                let puml_file = format!("{}.puml", filename);
544                tokio::fs::write(&puml_file, content).await?;
545
546                // Generate diagram using PlantUML
547                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                // PlantUML generates output with same base name
559                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}