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    // Explicit tool loading - admin controls what tools are available
78    pub async fn load_from_file(&mut self, path: &Path) -> Result<()> {
79        info!("Loading tools from: {}", path.display());
80
81        let content = tokio::fs::read_to_string(path)
82            .await
83            .context("Failed to read tools file")?;
84
85        // YAML parsing is the only text processing we can't avoid
86        let config: ToolsConfig = serde_yaml::from_str(&content).context("Failed to parse YAML")?;
87
88        // Process includes first
89        for include in &config.include {
90            let include_path = self.resolve_include_path(path, include)?;
91            info!("Including tools from: {}", include_path.display());
92
93            // Recursively load included files
94            Box::pin(self.load_from_file(&include_path)).await?;
95        }
96
97        // Then load tools from this file
98        for tool in config.tools {
99            info!("Loaded tool: {}", tool.name);
100            self.tools.insert(tool.name.clone(), tool);
101        }
102
103        Ok(())
104    }
105
106    fn resolve_include_path(&self, base_path: &Path, include: &str) -> Result<PathBuf> {
107        let base_dir = base_path
108            .parent()
109            .ok_or_else(|| anyhow::anyhow!("Cannot determine parent directory"))?;
110
111        // Support both relative and absolute paths
112        let include_path = if include.starts_with('/') {
113            PathBuf::from(include)
114        } else {
115            match include.starts_with("~/") {
116                true => {
117                    if let Some(home) = directories::UserDirs::new() {
118                        home.home_dir().join(&include[2..])
119                    } else {
120                        return Err(anyhow::anyhow!("Cannot resolve home directory"));
121                    }
122                }
123                false => {
124                    // Relative path
125                    base_dir.join(include)
126                }
127            }
128        };
129
130        if !include_path.exists() {
131            return Err(anyhow::anyhow!(
132                "Include file not found: {}",
133                include_path.display()
134            ));
135        }
136
137        Ok(include_path)
138    }
139
140    pub async fn load_from_default_locations(&mut self) -> Result<()> {
141        // Check for tools.yaml in various locations
142        let paths = vec![
143            PathBuf::from("./tools.yaml"),
144            PathBuf::from("~/.config/gamecode-mcp/tools.yaml"),
145        ];
146
147        if let Ok(tools_file) = std::env::var("GAMECODE_TOOLS_FILE") {
148            return self.load_from_file(Path::new(&tools_file)).await;
149        }
150
151        for path in paths {
152            let expanded = if path.starts_with("~") {
153                if let Some(home) = directories::UserDirs::new() {
154                    home.home_dir().join(path.strip_prefix("~").unwrap())
155                } else {
156                    continue;
157                }
158            } else {
159                path
160            };
161
162            if expanded.exists() {
163                return self.load_from_file(&expanded).await;
164            }
165        }
166
167        Err(anyhow::anyhow!("No tools.yaml file found"))
168    }
169
170    pub async fn load_mode(&mut self, mode: &str) -> Result<()> {
171        // Clear existing tools when switching modes
172        self.tools.clear();
173
174        // Load the mode-specific configuration
175        let mode_file = format!("tools/profiles/{}.yaml", mode);
176        let mode_path = PathBuf::from(&mode_file);
177
178        if mode_path.exists() {
179            self.load_from_file(&mode_path).await
180        } else {
181            // Try in config directory
182            if let Some(home) = directories::UserDirs::new() {
183                let config_path = home
184                    .home_dir()
185                    .join(".config/gamecode-mcp")
186                    .join(&mode_file);
187                if config_path.exists() {
188                    return self.load_from_file(&config_path).await;
189                }
190            }
191
192            Err(anyhow::anyhow!("Mode configuration '{}' not found", mode))
193        }
194    }
195
196    pub async fn detect_and_load_mode(&mut self) -> Result<()> {
197        // Auto-detect project type and load appropriate tools
198        let detections = vec![
199            ("Cargo.toml", "rust"),
200            ("package.json", "javascript"),
201            ("requirements.txt", "python"),
202            ("go.mod", "go"),
203            ("pom.xml", "java"),
204            ("build.gradle", "java"),
205            ("Gemfile", "ruby"),
206        ];
207
208        for (file, mode) in detections {
209            if PathBuf::from(file).exists() {
210                info!("Detected {} project, loading {} tools", mode, mode);
211
212                // Try to load language-specific tools
213                let lang_file = format!("tools/languages/{}.yaml", mode);
214                if PathBuf::from(&lang_file).exists() {
215                    self.load_from_file(Path::new(&lang_file)).await?;
216                }
217
218                // Always load core tools as well
219                if PathBuf::from("tools/core.yaml").exists() {
220                    self.load_from_file(Path::new("tools/core.yaml")).await?;
221                }
222
223                // Load git tools if .git exists
224                if PathBuf::from(".git").exists() && PathBuf::from("tools/git.yaml").exists() {
225                    self.load_from_file(Path::new("tools/git.yaml")).await?;
226                }
227
228                return Ok(());
229            }
230        }
231
232        // Default: just load from default locations
233        self.load_from_default_locations().await
234    }
235
236    // Convert to MCP schema - LLM sees exactly this, nothing hidden
237    pub fn get_mcp_tools(&self) -> Vec<Tool> {
238        self.tools
239            .values()
240            .map(|def| {
241                let mut properties = serde_json::Map::new();
242                let mut required = Vec::new();
243
244                // Build JSON schema from arg definitions
245                for arg in &def.args {
246                    let arg_schema = match arg.arg_type.as_str() {
247                        "string" => json!({
248                            "type": "string",
249                            "description": arg.description
250                        }),
251                        "number" => json!({
252                            "type": "number",
253                            "description": arg.description
254                        }),
255                        "boolean" => json!({
256                            "type": "boolean",
257                            "description": arg.description
258                        }),
259                        "array" => json!({
260                            "type": "array",
261                            "description": arg.description
262                        }),
263                        _ => json!({
264                            "type": "string",
265                            "description": arg.description
266                        }),
267                    };
268
269                    properties.insert(arg.name.clone(), arg_schema);
270
271                    if arg.required {
272                        required.push(json!(arg.name));
273                    }
274                }
275
276                let schema = json!({
277                    "type": "object",
278                    "properties": properties,
279                    "required": required
280                });
281
282                Tool {
283                    name: def.name.clone(),
284                    description: def.description.clone(),
285                    input_schema: schema,
286                }
287            })
288            .collect()
289    }
290
291    // Tool execution - the critical security boundary
292    pub async fn execute_tool(&self, name: &str, args: Value) -> Result<Value> {
293        let tool = self
294            .tools
295            .get(name)
296            .ok_or_else(|| anyhow::anyhow!("Tool '{}' not found", name))?;
297
298        // Internal handlers are hardcoded - no dynamic code execution
299        if let Some(handler) = &tool.internal_handler {
300            return self.execute_internal_handler(handler, &args).await;
301        }
302
303        // External commands - only what's explicitly configured
304        if tool.command.is_empty() || tool.command == "internal" {
305            return Err(anyhow::anyhow!("Tool '{}' has no command", name));
306        }
307
308        let mut cmd = Command::new(&tool.command);
309
310        // Add static flags
311        for flag in &tool.static_flags {
312            cmd.arg(flag);
313        }
314
315        // Argument construction - no shell interpretation, direct args only
316        if let Some(obj) = args.as_object() {
317            for arg_def in &tool.args {
318                if let Some(value) = obj.get(&arg_def.name) {
319                    // Optional validation
320                    if tool.validation.validate_args {
321                        validation::validate_typed_value(value, &arg_def.arg_type)?;
322                    }
323                    
324                    // Path validation if marked as path
325                    if arg_def.is_path && tool.validation.validate_paths {
326                        if let Some(path_str) = value.as_str() {
327                            validation::validate_path(path_str, tool.validation.allow_absolute_paths)?;
328                        }
329                    }
330                    
331                    let arg_value = value.to_string().trim_matches('"').to_string();
332                    
333                    if let Some(cli_flag) = &arg_def.cli_flag {
334                        cmd.arg(cli_flag);
335                        cmd.arg(&arg_value);
336                    } else {
337                        // Positional argument
338                        cmd.arg(&arg_value);
339                    }
340                }
341            }
342        }
343
344        debug!("Executing command: {:?}", cmd);
345
346        let output = cmd
347            .stdout(Stdio::piped())
348            .stderr(Stdio::piped())
349            .output()
350            .await
351            .context("Failed to execute command")?;
352
353        if output.status.success() {
354            let stdout = String::from_utf8_lossy(&output.stdout);
355
356            // Try to parse as JSON first
357            if let Ok(json_value) = serde_json::from_str::<Value>(&stdout) {
358                Ok(json_value)
359            } else {
360                Ok(json!({
361                    "output": stdout.trim(),
362                    "status": "success"
363                }))
364            }
365        } else {
366            let stderr = String::from_utf8_lossy(&output.stderr);
367            Err(anyhow::anyhow!("Command failed: {}", stderr))
368        }
369    }
370
371    // Internal handlers - hardcoded, no dynamic evaluation
372    async fn execute_internal_handler(&self, handler: &str, args: &Value) -> Result<Value> {
373        match handler {
374            "add" => {
375                let a = args
376                    .get("a")
377                    .and_then(|v| v.as_f64())
378                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
379                let b = args
380                    .get("b")
381                    .and_then(|v| v.as_f64())
382                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
383                Ok(json!({
384                    "result": a + b,
385                    "operation": "addition"
386                }))
387            }
388            "multiply" => {
389                let a = args
390                    .get("a")
391                    .and_then(|v| v.as_f64())
392                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'a'"))?;
393                let b = args
394                    .get("b")
395                    .and_then(|v| v.as_f64())
396                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'b'"))?;
397                Ok(json!({
398                    "result": a * b,
399                    "operation": "multiplication"
400                }))
401            }
402            "list_files" => {
403                let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
404
405                let mut files = Vec::new();
406                let mut entries = tokio::fs::read_dir(path).await?;
407
408                while let Some(entry) = entries.next_entry().await? {
409                    let metadata = entry.metadata().await?;
410                    files.push(json!({
411                        "name": entry.file_name().to_string_lossy(),
412                        "is_dir": metadata.is_dir(),
413                        "size": metadata.len()
414                    }));
415                }
416
417                Ok(json!({
418                    "path": path,
419                    "files": files
420                }))
421            }
422            "write_file" => {
423                let path = args
424                    .get("path")
425                    .and_then(|v| v.as_str())
426                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'path'"))?;
427                let content = args
428                    .get("content")
429                    .and_then(|v| v.as_str())
430                    .ok_or_else(|| anyhow::anyhow!("Missing parameter 'content'"))?;
431
432                tokio::fs::write(path, content).await?;
433
434                Ok(json!({
435                    "status": "success",
436                    "path": path,
437                    "bytes_written": content.len()
438                }))
439            }
440            _ => Err(anyhow::anyhow!("Unknown internal handler: {}", handler)),
441        }
442    }
443}