1use crate::{McpServer, Result};
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7use tokio::process::Command;
8
9pub struct GitServer {
11 root_dir: PathBuf,
13}
14
15impl GitServer {
16 pub fn new(root_dir: PathBuf) -> Self {
18 Self { root_dir }
19 }
20
21 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 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 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 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 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 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 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 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 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}