oxify_mcp/servers/
git.rs

1//! Git MCP server - provides Git operations
2
3use crate::{McpServer, Result};
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7use tokio::process::Command;
8
9/// Built-in MCP server for Git operations
10pub struct GitServer {
11    /// Root directory for Git operations (security boundary)
12    root_dir: PathBuf,
13}
14
15impl GitServer {
16    /// Create a new Git server with a root directory
17    pub fn new(root_dir: PathBuf) -> Self {
18        Self { root_dir }
19    }
20
21    /// Execute a git command
22    async fn git_command(&self, args: &[&str]) -> Result<std::process::Output> {
23        Command::new("git")
24            .args(args)
25            .current_dir(&self.root_dir)
26            .output()
27            .await
28            .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))
29    }
30
31    /// Resolve a repository path relative to root_dir
32    fn resolve_repo_path(&self, path: &str) -> Result<PathBuf> {
33        let requested_path = Path::new(path);
34
35        if requested_path.is_absolute() {
36            return Err(crate::McpError::InvalidRequest(
37                "Absolute paths not allowed".to_string(),
38            ));
39        }
40
41        let full_path = self.root_dir.join(requested_path);
42
43        // Ensure the path is within root_dir
44        let canonical = full_path
45            .canonicalize()
46            .map_err(|e| crate::McpError::InvalidRequest(format!("Invalid path: {}", e)))?;
47
48        if !canonical.starts_with(&self.root_dir) {
49            return Err(crate::McpError::InvalidRequest(
50                "Path outside allowed directory".to_string(),
51            ));
52        }
53
54        Ok(canonical)
55    }
56}
57
58#[async_trait]
59impl McpServer for GitServer {
60    async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value> {
61        match name {
62            "git_status" => {
63                let output = self.git_command(&["status", "--porcelain"]).await?;
64
65                Ok(json!({
66                    "status": String::from_utf8_lossy(&output.stdout),
67                    "success": output.status.success(),
68                }))
69            }
70
71            "git_clone" => {
72                let url = arguments["url"]
73                    .as_str()
74                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'url'".to_string()))?;
75                let path = arguments["path"]
76                    .as_str()
77                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
78
79                let resolved = self.resolve_repo_path(path)?;
80
81                let output = Command::new("git")
82                    .args(["clone", url, resolved.to_str().unwrap_or("")])
83                    .output()
84                    .await
85                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
86
87                Ok(json!({
88                    "success": output.status.success(),
89                    "stdout": String::from_utf8_lossy(&output.stdout),
90                    "stderr": String::from_utf8_lossy(&output.stderr),
91                }))
92            }
93
94            "git_add" => {
95                let files = arguments["files"].as_array().ok_or_else(|| {
96                    crate::McpError::InvalidRequest("Missing 'files' array".to_string())
97                })?;
98
99                let file_paths: Vec<String> = files
100                    .iter()
101                    .filter_map(|v| v.as_str())
102                    .map(|s| s.to_string())
103                    .collect();
104
105                if file_paths.is_empty() {
106                    return Err(crate::McpError::InvalidRequest(
107                        "No files specified".to_string(),
108                    ));
109                }
110
111                let mut args = vec!["add"];
112                let file_refs: Vec<&str> = file_paths.iter().map(|s| s.as_str()).collect();
113                args.extend(file_refs);
114
115                let output = self.git_command(&args).await?;
116
117                Ok(json!({
118                    "success": output.status.success(),
119                    "files_added": file_paths,
120                }))
121            }
122
123            "git_commit" => {
124                let message = arguments["message"].as_str().ok_or_else(|| {
125                    crate::McpError::InvalidRequest("Missing 'message'".to_string())
126                })?;
127
128                let output = self.git_command(&["commit", "-m", message]).await?;
129
130                Ok(json!({
131                    "success": output.status.success(),
132                    "stdout": String::from_utf8_lossy(&output.stdout),
133                    "stderr": String::from_utf8_lossy(&output.stderr),
134                }))
135            }
136
137            "git_push" => {
138                let remote = arguments["remote"].as_str().unwrap_or("origin");
139                let branch = arguments["branch"].as_str().unwrap_or("main");
140
141                let output = self.git_command(&["push", remote, branch]).await?;
142
143                Ok(json!({
144                    "success": output.status.success(),
145                    "stdout": String::from_utf8_lossy(&output.stdout),
146                    "stderr": String::from_utf8_lossy(&output.stderr),
147                }))
148            }
149
150            "git_pull" => {
151                let remote = arguments["remote"].as_str().unwrap_or("origin");
152                let branch = arguments["branch"].as_str().unwrap_or("main");
153
154                let output = self.git_command(&["pull", remote, branch]).await?;
155
156                Ok(json!({
157                    "success": output.status.success(),
158                    "stdout": String::from_utf8_lossy(&output.stdout),
159                    "stderr": String::from_utf8_lossy(&output.stderr),
160                }))
161            }
162
163            "git_log" => {
164                let limit = arguments["limit"].as_u64().unwrap_or(10);
165                let limit_str = limit.to_string();
166
167                let output = self
168                    .git_command(&["log", "--oneline", "-n", &limit_str])
169                    .await?;
170
171                Ok(json!({
172                    "log": String::from_utf8_lossy(&output.stdout),
173                    "success": output.status.success(),
174                }))
175            }
176
177            "git_diff" => {
178                let output = self.git_command(&["diff"]).await?;
179
180                Ok(json!({
181                    "diff": String::from_utf8_lossy(&output.stdout),
182                    "success": output.status.success(),
183                }))
184            }
185
186            "git_branch" => {
187                let output = self.git_command(&["branch", "-a"]).await?;
188
189                Ok(json!({
190                    "branches": String::from_utf8_lossy(&output.stdout),
191                    "success": output.status.success(),
192                }))
193            }
194
195            "git_checkout" => {
196                let branch = arguments["branch"].as_str().ok_or_else(|| {
197                    crate::McpError::InvalidRequest("Missing 'branch'".to_string())
198                })?;
199
200                let output = self.git_command(&["checkout", branch]).await?;
201
202                Ok(json!({
203                    "success": output.status.success(),
204                    "stdout": String::from_utf8_lossy(&output.stdout),
205                    "stderr": String::from_utf8_lossy(&output.stderr),
206                }))
207            }
208
209            _ => Err(crate::McpError::ToolNotFound(name.to_string())),
210        }
211    }
212
213    async fn list_tools(&self) -> Result<Vec<Value>> {
214        Ok(vec![
215            json!({
216                "name": "git_status",
217                "description": "Get repository status",
218                "inputSchema": {
219                    "type": "object",
220                    "properties": {}
221                }
222            }),
223            json!({
224                "name": "git_clone",
225                "description": "Clone a Git repository",
226                "inputSchema": {
227                    "type": "object",
228                    "properties": {
229                        "url": {
230                            "type": "string",
231                            "description": "Repository URL to clone"
232                        },
233                        "path": {
234                            "type": "string",
235                            "description": "Destination path"
236                        }
237                    },
238                    "required": ["url", "path"]
239                }
240            }),
241            json!({
242                "name": "git_add",
243                "description": "Stage files for commit",
244                "inputSchema": {
245                    "type": "object",
246                    "properties": {
247                        "files": {
248                            "type": "array",
249                            "items": {"type": "string"},
250                            "description": "Files to stage"
251                        }
252                    },
253                    "required": ["files"]
254                }
255            }),
256            json!({
257                "name": "git_commit",
258                "description": "Create a commit",
259                "inputSchema": {
260                    "type": "object",
261                    "properties": {
262                        "message": {
263                            "type": "string",
264                            "description": "Commit message"
265                        }
266                    },
267                    "required": ["message"]
268                }
269            }),
270            json!({
271                "name": "git_push",
272                "description": "Push commits to remote",
273                "inputSchema": {
274                    "type": "object",
275                    "properties": {
276                        "remote": {
277                            "type": "string",
278                            "description": "Remote name (default: origin)"
279                        },
280                        "branch": {
281                            "type": "string",
282                            "description": "Branch name (default: main)"
283                        }
284                    }
285                }
286            }),
287            json!({
288                "name": "git_pull",
289                "description": "Pull commits from remote",
290                "inputSchema": {
291                    "type": "object",
292                    "properties": {
293                        "remote": {
294                            "type": "string",
295                            "description": "Remote name (default: origin)"
296                        },
297                        "branch": {
298                            "type": "string",
299                            "description": "Branch name (default: main)"
300                        }
301                    }
302                }
303            }),
304            json!({
305                "name": "git_log",
306                "description": "View commit history",
307                "inputSchema": {
308                    "type": "object",
309                    "properties": {
310                        "limit": {
311                            "type": "number",
312                            "description": "Number of commits to show (default: 10)"
313                        }
314                    }
315                }
316            }),
317            json!({
318                "name": "git_diff",
319                "description": "Show changes",
320                "inputSchema": {
321                    "type": "object",
322                    "properties": {}
323                }
324            }),
325            json!({
326                "name": "git_branch",
327                "description": "List branches",
328                "inputSchema": {
329                    "type": "object",
330                    "properties": {}
331                }
332            }),
333            json!({
334                "name": "git_checkout",
335                "description": "Switch branches",
336                "inputSchema": {
337                    "type": "object",
338                    "properties": {
339                        "branch": {
340                            "type": "string",
341                            "description": "Branch name to switch to"
342                        }
343                    },
344                    "required": ["branch"]
345                }
346            }),
347        ])
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use serde_json::json;
355    use std::fs;
356
357    #[tokio::test]
358    async fn test_git_server_creation() {
359        let temp_dir = std::env::temp_dir().join("oxify-git-test");
360        fs::create_dir_all(&temp_dir).unwrap();
361
362        let server = GitServer::new(temp_dir.clone());
363        let tools = server.list_tools().await.unwrap();
364        assert_eq!(tools.len(), 10);
365
366        fs::remove_dir_all(&temp_dir).unwrap();
367    }
368
369    #[tokio::test]
370    async fn test_git_list_tools() {
371        let server = GitServer::new(PathBuf::from("."));
372        let tools = server.list_tools().await.unwrap();
373
374        assert!(tools.iter().any(|t| t["name"] == "git_status"));
375        assert!(tools.iter().any(|t| t["name"] == "git_clone"));
376        assert!(tools.iter().any(|t| t["name"] == "git_add"));
377        assert!(tools.iter().any(|t| t["name"] == "git_commit"));
378        assert!(tools.iter().any(|t| t["name"] == "git_push"));
379        assert!(tools.iter().any(|t| t["name"] == "git_pull"));
380        assert!(tools.iter().any(|t| t["name"] == "git_log"));
381        assert!(tools.iter().any(|t| t["name"] == "git_diff"));
382        assert!(tools.iter().any(|t| t["name"] == "git_branch"));
383        assert!(tools.iter().any(|t| t["name"] == "git_checkout"));
384    }
385
386    #[tokio::test]
387    async fn test_git_status() {
388        // Only run if we're in a git repository
389        if PathBuf::from(".git").exists() {
390            let server = GitServer::new(PathBuf::from("."));
391
392            let result = server.call_tool("git_status", json!({})).await.unwrap();
393
394            assert!(result["success"].as_bool().unwrap_or(false));
395        }
396    }
397
398    #[tokio::test]
399    async fn test_git_log() {
400        // Only run if we're in a git repository
401        if PathBuf::from(".git").exists() {
402            let server = GitServer::new(PathBuf::from("."));
403
404            let result = server
405                .call_tool(
406                    "git_log",
407                    json!({
408                        "limit": 5
409                    }),
410                )
411                .await
412                .unwrap();
413
414            assert!(result["success"].as_bool().unwrap_or(false));
415        }
416    }
417
418    #[tokio::test]
419    async fn test_git_branch() {
420        // Only run if we're in a git repository
421        if PathBuf::from(".git").exists() {
422            let server = GitServer::new(PathBuf::from("."));
423
424            let result = server.call_tool("git_branch", json!({})).await.unwrap();
425
426            assert!(result["success"].as_bool().unwrap_or(false));
427        }
428    }
429
430    #[tokio::test]
431    async fn test_git_diff() {
432        // Only run if we're in a git repository
433        if PathBuf::from(".git").exists() {
434            let server = GitServer::new(PathBuf::from("."));
435
436            let result = server.call_tool("git_diff", json!({})).await.unwrap();
437
438            // diff might be empty, but should succeed
439            assert!(result.get("success").is_some());
440        }
441    }
442
443    #[tokio::test]
444    async fn test_git_add_missing_files() {
445        let temp_dir = std::env::temp_dir().join("oxify-git-test-add");
446        fs::create_dir_all(&temp_dir).unwrap();
447
448        let server = GitServer::new(temp_dir.clone());
449
450        let result = server
451            .call_tool(
452                "git_add",
453                json!({
454                    "files": []
455                }),
456            )
457            .await;
458
459        assert!(result.is_err());
460
461        fs::remove_dir_all(&temp_dir).unwrap();
462    }
463
464    #[tokio::test]
465    async fn test_git_commit_missing_message() {
466        let temp_dir = std::env::temp_dir().join("oxify-git-test-commit");
467        fs::create_dir_all(&temp_dir).unwrap();
468
469        let server = GitServer::new(temp_dir.clone());
470
471        let result = server.call_tool("git_commit", json!({})).await;
472
473        assert!(result.is_err());
474
475        fs::remove_dir_all(&temp_dir).unwrap();
476    }
477
478    #[tokio::test]
479    async fn test_git_checkout_missing_branch() {
480        let temp_dir = std::env::temp_dir().join("oxify-git-test-checkout");
481        fs::create_dir_all(&temp_dir).unwrap();
482
483        let server = GitServer::new(temp_dir.clone());
484
485        let result = server.call_tool("git_checkout", json!({})).await;
486
487        assert!(result.is_err());
488
489        fs::remove_dir_all(&temp_dir).unwrap();
490    }
491
492    #[tokio::test]
493    async fn test_git_clone_missing_url() {
494        let temp_dir = std::env::temp_dir().join("oxify-git-test-clone");
495        fs::create_dir_all(&temp_dir).unwrap();
496
497        let server = GitServer::new(temp_dir.clone());
498
499        let result = server
500            .call_tool(
501                "git_clone",
502                json!({
503                    "path": "test"
504                }),
505            )
506            .await;
507
508        assert!(result.is_err());
509
510        fs::remove_dir_all(&temp_dir).unwrap();
511    }
512
513    #[tokio::test]
514    async fn test_git_clone_missing_path() {
515        let temp_dir = std::env::temp_dir().join("oxify-git-test-clone2");
516        fs::create_dir_all(&temp_dir).unwrap();
517
518        let server = GitServer::new(temp_dir.clone());
519
520        let result = server
521            .call_tool(
522                "git_clone",
523                json!({
524                    "url": "https://example.com/repo.git"
525                }),
526            )
527            .await;
528
529        assert!(result.is_err());
530
531        fs::remove_dir_all(&temp_dir).unwrap();
532    }
533
534    #[tokio::test]
535    async fn test_git_invalid_tool() {
536        let server = GitServer::new(PathBuf::from("."));
537
538        let result = server.call_tool("nonexistent_tool", json!({})).await;
539
540        assert!(result.is_err());
541    }
542
543    #[tokio::test]
544    async fn test_git_path_security() {
545        let temp_dir = std::env::temp_dir().join("oxify-git-security");
546        fs::create_dir_all(&temp_dir).unwrap();
547
548        let server = GitServer::new(temp_dir.clone());
549
550        // Try to clone to an absolute path (should fail)
551        let result = server
552            .call_tool(
553                "git_clone",
554                json!({
555                    "url": "https://example.com/repo.git",
556                    "path": "/etc/test"
557                }),
558            )
559            .await;
560
561        assert!(result.is_err());
562
563        fs::remove_dir_all(&temp_dir).unwrap();
564    }
565}