mcp_tools/servers/
git_tools.rs

1//! Git Tools MCP Server
2//!
3//! Provides Git repository operations via MCP protocol including:
4//! - Repository status and diff analysis
5//! - Commit history and blame information
6//! - Branch analysis and merge conflict detection
7//! - Change impact analysis
8
9use async_trait::async_trait;
10use git2::{BlameOptions, DiffOptions, Repository, Status, StatusOptions};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use tracing::{debug, error, info, warn};
15
16use crate::common::{
17    BaseServer, McpContent, McpServerBase, McpTool, McpToolRequest, McpToolResponse,
18    ServerCapabilities, ServerConfig,
19};
20use crate::{McpToolsError, Result};
21
22/// Git Tools MCP Server
23pub struct GitToolsServer {
24    base: BaseServer,
25}
26
27/// Git repository status information
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct GitStatus {
30    pub branch: String,
31    pub ahead: usize,
32    pub behind: usize,
33    pub modified: Vec<String>,
34    pub added: Vec<String>,
35    pub deleted: Vec<String>,
36    pub untracked: Vec<String>,
37    pub conflicted: Vec<String>,
38}
39
40/// Git commit information
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct CommitInfo {
43    pub id: String,
44    pub short_id: String,
45    pub message: String,
46    pub author: String,
47    pub email: String,
48    pub timestamp: i64,
49    pub files_changed: Vec<String>,
50}
51
52/// Git diff information
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DiffInfo {
55    pub file_path: String,
56    pub status: String,
57    pub additions: usize,
58    pub deletions: usize,
59    pub hunks: Vec<DiffHunk>,
60}
61
62/// Git diff hunk
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DiffHunk {
65    pub old_start: usize,
66    pub old_lines: usize,
67    pub new_start: usize,
68    pub new_lines: usize,
69    pub header: String,
70}
71
72/// Git blame information
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BlameInfo {
75    pub file_path: String,
76    pub lines: Vec<BlameLine>,
77}
78
79/// Individual blame line
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct BlameLine {
82    pub line_number: usize,
83    pub content: String,
84    pub commit_id: String,
85    pub author: String,
86    pub timestamp: i64,
87}
88
89/// Branch information
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct BranchInfo {
92    pub name: String,
93    pub is_current: bool,
94    pub is_remote: bool,
95    pub last_commit: String,
96    pub ahead: usize,
97    pub behind: usize,
98}
99
100impl GitToolsServer {
101    pub async fn new(config: ServerConfig) -> Result<Self> {
102        let base = BaseServer::new(config).await?;
103        Ok(Self { base })
104    }
105
106    /// Get repository instance (no caching for thread safety)
107    fn get_repository(&self, repo_path: &Path) -> Result<Repository> {
108        let canonical_path = repo_path
109            .canonicalize()
110            .map_err(|e| McpToolsError::Server(format!("Invalid repository path: {}", e)))?;
111
112        Repository::discover(&canonical_path)
113            .map_err(|e| McpToolsError::Server(format!("Git repository not found: {}", e)))
114    }
115
116    /// Get repository status
117    async fn get_status(&self, repo_path: &Path) -> Result<GitStatus> {
118        let repo = self.get_repository(repo_path)?;
119
120        // Get current branch
121        let head = repo
122            .head()
123            .map_err(|e| McpToolsError::Server(format!("Failed to get HEAD: {}", e)))?;
124        let branch = head.shorthand().unwrap_or("HEAD").to_string();
125
126        // Get ahead/behind counts (simplified implementation)
127        let (ahead, behind) = (0, 0); // Would need upstream comparison for accurate counts
128
129        // Get file statuses
130        let mut status_opts = StatusOptions::new();
131        status_opts.include_untracked(true);
132        status_opts.include_ignored(false);
133
134        let statuses = repo
135            .statuses(Some(&mut status_opts))
136            .map_err(|e| McpToolsError::Server(format!("Failed to get status: {}", e)))?;
137
138        let mut modified = Vec::new();
139        let mut added = Vec::new();
140        let mut deleted = Vec::new();
141        let mut untracked = Vec::new();
142        let mut conflicted = Vec::new();
143
144        for entry in statuses.iter() {
145            let path = entry.path().unwrap_or("").to_string();
146            let status = entry.status();
147
148            if status.contains(Status::CONFLICTED) {
149                conflicted.push(path);
150            } else if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
151                added.push(path);
152            } else if status.contains(Status::WT_MODIFIED)
153                || status.contains(Status::INDEX_MODIFIED)
154            {
155                modified.push(path);
156            } else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED)
157            {
158                deleted.push(path);
159            } else if status.contains(Status::WT_NEW) {
160                untracked.push(path);
161            }
162        }
163
164        Ok(GitStatus {
165            branch,
166            ahead,
167            behind,
168            modified,
169            added,
170            deleted,
171            untracked,
172            conflicted,
173        })
174    }
175
176    /// Get repository diff
177    async fn get_diff(
178        &self,
179        repo_path: &Path,
180        params: &serde_json::Value,
181    ) -> Result<Vec<DiffInfo>> {
182        let repo = self.get_repository(repo_path)?;
183
184        let staged = params
185            .get("staged")
186            .and_then(|v| v.as_str())
187            .map(|s| s == "true")
188            .unwrap_or(false);
189
190        let diff = if staged {
191            // Staged changes (index vs HEAD)
192            let head_tree = repo.head()?.peel_to_tree()?;
193            let index = repo.index()?;
194            repo.diff_tree_to_index(Some(&head_tree), Some(&index), None)?
195        } else {
196            // Working directory changes
197            repo.diff_index_to_workdir(None, None)?
198        };
199
200        let mut diff_infos = Vec::new();
201
202        diff.foreach(
203            &mut |delta, _progress| {
204                if let Some(new_file) = delta.new_file().path() {
205                    let file_path = new_file.to_string_lossy().to_string();
206                    let status = match delta.status() {
207                        git2::Delta::Added => "added",
208                        git2::Delta::Deleted => "deleted",
209                        git2::Delta::Modified => "modified",
210                        git2::Delta::Renamed => "renamed",
211                        git2::Delta::Copied => "copied",
212                        _ => "unknown",
213                    }
214                    .to_string();
215
216                    diff_infos.push(DiffInfo {
217                        file_path,
218                        status,
219                        additions: 0, // Would need to parse hunks for accurate counts
220                        deletions: 0,
221                        hunks: Vec::new(), // Simplified for now
222                    });
223                }
224                true
225            },
226            None,
227            None,
228            None,
229        )?;
230
231        Ok(diff_infos)
232    }
233
234    /// Get commit log
235    async fn get_log(
236        &self,
237        repo_path: &Path,
238        params: &serde_json::Value,
239    ) -> Result<Vec<CommitInfo>> {
240        let repo = self.get_repository(repo_path)?;
241
242        let limit = params
243            .get("limit")
244            .and_then(|v| v.as_str())
245            .and_then(|s| s.parse::<usize>().ok())
246            .unwrap_or(10);
247
248        let file_path = params.get("file_path").and_then(|v| v.as_str());
249
250        let mut revwalk = repo
251            .revwalk()
252            .map_err(|e| McpToolsError::Server(format!("Failed to create revwalk: {}", e)))?;
253        revwalk
254            .push_head()
255            .map_err(|e| McpToolsError::Server(format!("Failed to push HEAD: {}", e)))?;
256        revwalk
257            .set_sorting(git2::Sort::TIME)
258            .map_err(|e| McpToolsError::Server(format!("Failed to set sorting: {}", e)))?;
259
260        let mut commits = Vec::new();
261        let mut count = 0;
262
263        for oid in revwalk {
264            if count >= limit {
265                break;
266            }
267
268            let oid =
269                oid.map_err(|e| McpToolsError::Server(format!("Failed to get OID: {}", e)))?;
270            let commit = repo
271                .find_commit(oid)
272                .map_err(|e| McpToolsError::Server(format!("Failed to find commit: {}", e)))?;
273
274            // Filter by file path if specified
275            if let Some(file_path) = file_path {
276                // This is simplified - would need proper path filtering
277                debug!("Filtering by file path: {}", file_path);
278            }
279
280            let author = commit.author();
281            let files_changed = Vec::new(); // Would need to analyze diff for accurate file list
282
283            commits.push(CommitInfo {
284                id: commit.id().to_string(),
285                short_id: commit.id().to_string()[..8].to_string(),
286                message: commit.message().unwrap_or("").to_string(),
287                author: author.name().unwrap_or("").to_string(),
288                email: author.email().unwrap_or("").to_string(),
289                timestamp: author.when().seconds(),
290                files_changed,
291            });
292
293            count += 1;
294        }
295
296        Ok(commits)
297    }
298
299    /// Get blame information
300    async fn get_blame(&self, repo_path: &Path, params: &serde_json::Value) -> Result<BlameInfo> {
301        let repo = self.get_repository(repo_path)?;
302        let file_path = params
303            .get("file_path")
304            .and_then(|v| v.as_str())
305            .ok_or_else(|| {
306                McpToolsError::Server("file_path parameter required for blame".to_string())
307            })?;
308
309        let mut blame_opts = BlameOptions::new();
310        let blame = repo
311            .blame_file(Path::new(file_path), Some(&mut blame_opts))
312            .map_err(|e| McpToolsError::Server(format!("Failed to get blame: {}", e)))?;
313
314        // Read file content to get line content
315        let full_path = repo
316            .workdir()
317            .ok_or_else(|| McpToolsError::Server("Bare repository not supported".to_string()))?
318            .join(file_path);
319
320        let content = std::fs::read_to_string(&full_path)
321            .map_err(|e| McpToolsError::Server(format!("Failed to read file: {}", e)))?;
322
323        let lines: Vec<&str> = content.lines().collect();
324        let mut blame_lines = Vec::new();
325
326        for (line_num, line_content) in lines.iter().enumerate() {
327            if let Some(hunk) = blame.get_line(line_num + 1) {
328                let commit = repo
329                    .find_commit(hunk.final_commit_id())
330                    .map_err(|e| McpToolsError::Server(format!("Failed to find commit: {}", e)))?;
331                let author = commit.author();
332
333                blame_lines.push(BlameLine {
334                    line_number: line_num + 1,
335                    content: line_content.to_string(),
336                    commit_id: hunk.final_commit_id().to_string(),
337                    author: author.name().unwrap_or("").to_string(),
338                    timestamp: author.when().seconds(),
339                });
340            }
341        }
342
343        Ok(BlameInfo {
344            file_path: file_path.to_string(),
345            lines: blame_lines,
346        })
347    }
348
349    /// Get branch information
350    async fn get_branches(&self, repo_path: &Path) -> Result<Vec<BranchInfo>> {
351        let repo = self.get_repository(repo_path)?;
352
353        let branches = repo
354            .branches(Some(git2::BranchType::Local))
355            .map_err(|e| McpToolsError::Server(format!("Failed to get branches: {}", e)))?;
356        let mut branch_infos = Vec::new();
357
358        let current_branch = repo
359            .head()
360            .ok()
361            .and_then(|head| head.shorthand().map(|s| s.to_string()))
362            .unwrap_or_default();
363
364        for branch_result in branches {
365            let (branch, _) = branch_result
366                .map_err(|e| McpToolsError::Server(format!("Failed to process branch: {}", e)))?;
367
368            if let Some(name) = branch
369                .name()
370                .map_err(|e| McpToolsError::Server(format!("Failed to get branch name: {}", e)))?
371            {
372                let is_current = name == current_branch;
373                let last_commit = if let Some(oid) = branch.get().target() {
374                    oid.to_string()[..8].to_string()
375                } else {
376                    "unknown".to_string()
377                };
378
379                branch_infos.push(BranchInfo {
380                    name: name.to_string(),
381                    is_current,
382                    is_remote: false,
383                    last_commit,
384                    ahead: 0, // Would need upstream comparison
385                    behind: 0,
386                });
387            }
388        }
389
390        Ok(branch_infos)
391    }
392}
393
394#[async_trait]
395impl McpServerBase for GitToolsServer {
396    async fn get_capabilities(&self) -> Result<ServerCapabilities> {
397        let mut capabilities = self.base.get_capabilities().await?;
398
399        // Add Git-specific tools
400        let git_tools = vec![
401            McpTool {
402                name: "git_status".to_string(),
403                description: "Get Git repository status including modified, added, deleted files"
404                    .to_string(),
405                input_schema: serde_json::json!({
406                    "type": "object",
407                    "properties": {
408                        "repo_path": {
409                            "type": "string",
410                            "description": "Path to Git repository (optional, defaults to current directory)"
411                        }
412                    }
413                }),
414                category: "git".to_string(),
415                requires_permission: false,
416                permissions: vec![],
417            },
418            McpTool {
419                name: "git_diff".to_string(),
420                description: "Get Git diff information for repository changes".to_string(),
421                input_schema: serde_json::json!({
422                    "type": "object",
423                    "properties": {
424                        "repo_path": {
425                            "type": "string",
426                            "description": "Path to Git repository (optional, defaults to current directory)"
427                        },
428                        "staged": {
429                            "type": "string",
430                            "description": "Show staged changes (true/false, default: false)"
431                        }
432                    }
433                }),
434                category: "git".to_string(),
435                requires_permission: false,
436                permissions: vec![],
437            },
438            McpTool {
439                name: "git_log".to_string(),
440                description: "Get Git commit history".to_string(),
441                input_schema: serde_json::json!({
442                    "type": "object",
443                    "properties": {
444                        "repo_path": {
445                            "type": "string",
446                            "description": "Path to Git repository (optional, defaults to current directory)"
447                        },
448                        "limit": {
449                            "type": "string",
450                            "description": "Number of commits to show (default: 10)"
451                        },
452                        "file_path": {
453                            "type": "string",
454                            "description": "Filter commits by specific file path (optional)"
455                        }
456                    }
457                }),
458                category: "git".to_string(),
459                requires_permission: false,
460                permissions: vec![],
461            },
462            McpTool {
463                name: "git_blame".to_string(),
464                description: "Get Git blame information for a file".to_string(),
465                input_schema: serde_json::json!({
466                    "type": "object",
467                    "properties": {
468                        "repo_path": {
469                            "type": "string",
470                            "description": "Path to Git repository (optional, defaults to current directory)"
471                        },
472                        "file_path": {
473                            "type": "string",
474                            "description": "Path to file for blame information"
475                        }
476                    },
477                    "required": ["file_path"]
478                }),
479                category: "git".to_string(),
480                requires_permission: false,
481                permissions: vec![],
482            },
483            McpTool {
484                name: "git_branches".to_string(),
485                description: "Get Git branch information".to_string(),
486                input_schema: serde_json::json!({
487                    "type": "object",
488                    "properties": {
489                        "repo_path": {
490                            "type": "string",
491                            "description": "Path to Git repository (optional, defaults to current directory)"
492                        }
493                    }
494                }),
495                category: "git".to_string(),
496                requires_permission: false,
497                permissions: vec![],
498            },
499        ];
500
501        capabilities.tools = git_tools;
502        Ok(capabilities)
503    }
504
505    async fn handle_tool_request(&self, request: McpToolRequest) -> Result<McpToolResponse> {
506        info!("Handling Git tool request: {}", request.tool);
507
508        let repo_path = request
509            .arguments
510            .get("repo_path")
511            .and_then(|v| v.as_str())
512            .map(PathBuf::from)
513            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
514
515        match request.tool.as_str() {
516            "git_status" => {
517                debug!("Getting Git status for: {}", repo_path.display());
518                let status = self.get_status(&repo_path).await?;
519                let content_text = format!(
520                    "Git Status for {}\n\
521                    Branch: {}\n\
522                    Ahead: {}, Behind: {}\n\
523                    Modified: {}\n\
524                    Added: {}\n\
525                    Deleted: {}\n\
526                    Untracked: {}\n\
527                    Conflicted: {}",
528                    repo_path.display(),
529                    status.branch,
530                    status.ahead,
531                    status.behind,
532                    status.modified.len(),
533                    status.added.len(),
534                    status.deleted.len(),
535                    status.untracked.len(),
536                    status.conflicted.len()
537                );
538
539                let mut metadata = HashMap::new();
540                metadata.insert("git_status".to_string(), serde_json::to_value(status)?);
541
542                Ok(McpToolResponse {
543                    id: request.id,
544                    content: vec![McpContent::text(content_text)],
545                    is_error: false,
546                    error: None,
547                    metadata,
548                })
549            }
550            "git_diff" => {
551                debug!("Getting Git diff for: {}", repo_path.display());
552                let diff_infos = self.get_diff(&repo_path, &request.arguments).await?;
553                let content_text = format!(
554                    "Git Diff for {}\n\
555                    Files changed: {}",
556                    repo_path.display(),
557                    diff_infos.len()
558                );
559
560                let mut metadata = HashMap::new();
561                metadata.insert("git_diff".to_string(), serde_json::to_value(diff_infos)?);
562
563                Ok(McpToolResponse {
564                    id: request.id,
565                    content: vec![McpContent::text(content_text)],
566                    is_error: false,
567                    error: None,
568                    metadata,
569                })
570            }
571            "git_log" => {
572                debug!("Getting Git log for: {}", repo_path.display());
573                let commits = self.get_log(&repo_path, &request.arguments).await?;
574                let content_text = format!(
575                    "Git Log for {}\n\
576                    Commits: {}",
577                    repo_path.display(),
578                    commits.len()
579                );
580
581                let mut metadata = HashMap::new();
582                metadata.insert("git_log".to_string(), serde_json::to_value(commits)?);
583
584                Ok(McpToolResponse {
585                    id: request.id,
586                    content: vec![McpContent::text(content_text)],
587                    is_error: false,
588                    error: None,
589                    metadata,
590                })
591            }
592            "git_blame" => {
593                debug!("Getting Git blame for: {}", repo_path.display());
594                let blame_info = self.get_blame(&repo_path, &request.arguments).await?;
595                let content_text = format!(
596                    "Git Blame for {}\n\
597                    Lines: {}",
598                    blame_info.file_path,
599                    blame_info.lines.len()
600                );
601
602                let mut metadata = HashMap::new();
603                metadata.insert("git_blame".to_string(), serde_json::to_value(blame_info)?);
604
605                Ok(McpToolResponse {
606                    id: request.id,
607                    content: vec![McpContent::text(content_text)],
608                    is_error: false,
609                    error: None,
610                    metadata,
611                })
612            }
613            "git_branches" => {
614                debug!("Getting Git branches for: {}", repo_path.display());
615                let branches = self.get_branches(&repo_path).await?;
616                let current = branches.iter().find(|b| b.is_current);
617                let content_text = format!(
618                    "Git Branches for {}\n\
619                    Total: {}\n\
620                    Current: {}",
621                    repo_path.display(),
622                    branches.len(),
623                    current.map(|b| b.name.as_str()).unwrap_or("None")
624                );
625
626                let mut metadata = HashMap::new();
627                metadata.insert("git_branches".to_string(), serde_json::to_value(branches)?);
628
629                Ok(McpToolResponse {
630                    id: request.id,
631                    content: vec![McpContent::text(content_text)],
632                    is_error: false,
633                    error: None,
634                    metadata,
635                })
636            }
637            _ => {
638                warn!("Unknown Git tool: {}", request.tool);
639                Err(McpToolsError::Server(format!(
640                    "Unknown Git tool: {}",
641                    request.tool
642                )))
643            }
644        }
645    }
646
647    async fn get_stats(&self) -> Result<crate::common::ServerStats> {
648        self.base.get_stats().await
649    }
650
651    async fn initialize(&mut self) -> Result<()> {
652        info!("Initializing Git Tools MCP Server");
653        Ok(())
654    }
655
656    async fn shutdown(&mut self) -> Result<()> {
657        info!("Shutting down Git Tools MCP Server");
658        Ok(())
659    }
660}