use async_trait::async_trait;
use git2::{BlameOptions, DiffOptions, Repository, Status, StatusOptions};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info, warn};
use crate::common::{
BaseServer, McpContent, McpServerBase, McpTool, McpToolRequest, McpToolResponse,
ServerCapabilities, ServerConfig,
};
use crate::{McpToolsError, Result};
pub struct GitToolsServer {
base: BaseServer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub branch: String,
pub ahead: usize,
pub behind: usize,
pub modified: Vec<String>,
pub added: Vec<String>,
pub deleted: Vec<String>,
pub untracked: Vec<String>,
pub conflicted: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitInfo {
pub id: String,
pub short_id: String,
pub message: String,
pub author: String,
pub email: String,
pub timestamp: i64,
pub files_changed: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffInfo {
pub file_path: String,
pub status: String,
pub additions: usize,
pub deletions: usize,
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffHunk {
pub old_start: usize,
pub old_lines: usize,
pub new_start: usize,
pub new_lines: usize,
pub header: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameInfo {
pub file_path: String,
pub lines: Vec<BlameLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameLine {
pub line_number: usize,
pub content: String,
pub commit_id: String,
pub author: String,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfo {
pub name: String,
pub is_current: bool,
pub is_remote: bool,
pub last_commit: String,
pub ahead: usize,
pub behind: usize,
}
impl GitToolsServer {
pub async fn new(config: ServerConfig) -> Result<Self> {
let base = BaseServer::new(config).await?;
Ok(Self { base })
}
fn get_repository(&self, repo_path: &Path) -> Result<Repository> {
let canonical_path = repo_path
.canonicalize()
.map_err(|e| McpToolsError::Server(format!("Invalid repository path: {}", e)))?;
Repository::discover(&canonical_path)
.map_err(|e| McpToolsError::Server(format!("Git repository not found: {}", e)))
}
async fn get_status(&self, repo_path: &Path) -> Result<GitStatus> {
let repo = self.get_repository(repo_path)?;
let head = repo
.head()
.map_err(|e| McpToolsError::Server(format!("Failed to get HEAD: {}", e)))?;
let branch = head.shorthand().unwrap_or("HEAD").to_string();
let (ahead, behind) = (0, 0);
let mut status_opts = StatusOptions::new();
status_opts.include_untracked(true);
status_opts.include_ignored(false);
let statuses = repo
.statuses(Some(&mut status_opts))
.map_err(|e| McpToolsError::Server(format!("Failed to get status: {}", e)))?;
let mut modified = Vec::new();
let mut added = Vec::new();
let mut deleted = Vec::new();
let mut untracked = Vec::new();
let mut conflicted = Vec::new();
for entry in statuses.iter() {
let path = entry.path().unwrap_or("").to_string();
let status = entry.status();
if status.contains(Status::CONFLICTED) {
conflicted.push(path);
} else if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
added.push(path);
} else if status.contains(Status::WT_MODIFIED)
|| status.contains(Status::INDEX_MODIFIED)
{
modified.push(path);
} else if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED)
{
deleted.push(path);
} else if status.contains(Status::WT_NEW) {
untracked.push(path);
}
}
Ok(GitStatus {
branch,
ahead,
behind,
modified,
added,
deleted,
untracked,
conflicted,
})
}
async fn get_diff(
&self,
repo_path: &Path,
params: &serde_json::Value,
) -> Result<Vec<DiffInfo>> {
let repo = self.get_repository(repo_path)?;
let staged = params
.get("staged")
.and_then(|v| v.as_str())
.map(|s| s == "true")
.unwrap_or(false);
let diff = if staged {
let head_tree = repo.head()?.peel_to_tree()?;
let index = repo.index()?;
repo.diff_tree_to_index(Some(&head_tree), Some(&index), None)?
} else {
repo.diff_index_to_workdir(None, None)?
};
let mut diff_infos = Vec::new();
diff.foreach(
&mut |delta, _progress| {
if let Some(new_file) = delta.new_file().path() {
let file_path = new_file.to_string_lossy().to_string();
let status = match delta.status() {
git2::Delta::Added => "added",
git2::Delta::Deleted => "deleted",
git2::Delta::Modified => "modified",
git2::Delta::Renamed => "renamed",
git2::Delta::Copied => "copied",
_ => "unknown",
}
.to_string();
diff_infos.push(DiffInfo {
file_path,
status,
additions: 0, deletions: 0,
hunks: Vec::new(), });
}
true
},
None,
None,
None,
)?;
Ok(diff_infos)
}
async fn get_log(
&self,
repo_path: &Path,
params: &serde_json::Value,
) -> Result<Vec<CommitInfo>> {
let repo = self.get_repository(repo_path)?;
let limit = params
.get("limit")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(10);
let file_path = params.get("file_path").and_then(|v| v.as_str());
let mut revwalk = repo
.revwalk()
.map_err(|e| McpToolsError::Server(format!("Failed to create revwalk: {}", e)))?;
revwalk
.push_head()
.map_err(|e| McpToolsError::Server(format!("Failed to push HEAD: {}", e)))?;
revwalk
.set_sorting(git2::Sort::TIME)
.map_err(|e| McpToolsError::Server(format!("Failed to set sorting: {}", e)))?;
let mut commits = Vec::new();
let mut count = 0;
for oid in revwalk {
if count >= limit {
break;
}
let oid =
oid.map_err(|e| McpToolsError::Server(format!("Failed to get OID: {}", e)))?;
let commit = repo
.find_commit(oid)
.map_err(|e| McpToolsError::Server(format!("Failed to find commit: {}", e)))?;
if let Some(file_path) = file_path {
debug!("Filtering by file path: {}", file_path);
}
let author = commit.author();
let files_changed = Vec::new();
commits.push(CommitInfo {
id: commit.id().to_string(),
short_id: commit.id().to_string()[..8].to_string(),
message: commit.message().unwrap_or("").to_string(),
author: author.name().unwrap_or("").to_string(),
email: author.email().unwrap_or("").to_string(),
timestamp: author.when().seconds(),
files_changed,
});
count += 1;
}
Ok(commits)
}
async fn get_blame(&self, repo_path: &Path, params: &serde_json::Value) -> Result<BlameInfo> {
let repo = self.get_repository(repo_path)?;
let file_path = params
.get("file_path")
.and_then(|v| v.as_str())
.ok_or_else(|| {
McpToolsError::Server("file_path parameter required for blame".to_string())
})?;
let mut blame_opts = BlameOptions::new();
let blame = repo
.blame_file(Path::new(file_path), Some(&mut blame_opts))
.map_err(|e| McpToolsError::Server(format!("Failed to get blame: {}", e)))?;
let full_path = repo
.workdir()
.ok_or_else(|| McpToolsError::Server("Bare repository not supported".to_string()))?
.join(file_path);
let content = std::fs::read_to_string(&full_path)
.map_err(|e| McpToolsError::Server(format!("Failed to read file: {}", e)))?;
let lines: Vec<&str> = content.lines().collect();
let mut blame_lines = Vec::new();
for (line_num, line_content) in lines.iter().enumerate() {
if let Some(hunk) = blame.get_line(line_num + 1) {
let commit = repo
.find_commit(hunk.final_commit_id())
.map_err(|e| McpToolsError::Server(format!("Failed to find commit: {}", e)))?;
let author = commit.author();
blame_lines.push(BlameLine {
line_number: line_num + 1,
content: line_content.to_string(),
commit_id: hunk.final_commit_id().to_string(),
author: author.name().unwrap_or("").to_string(),
timestamp: author.when().seconds(),
});
}
}
Ok(BlameInfo {
file_path: file_path.to_string(),
lines: blame_lines,
})
}
async fn get_branches(&self, repo_path: &Path) -> Result<Vec<BranchInfo>> {
let repo = self.get_repository(repo_path)?;
let branches = repo
.branches(Some(git2::BranchType::Local))
.map_err(|e| McpToolsError::Server(format!("Failed to get branches: {}", e)))?;
let mut branch_infos = Vec::new();
let current_branch = repo
.head()
.ok()
.and_then(|head| head.shorthand().map(|s| s.to_string()))
.unwrap_or_default();
for branch_result in branches {
let (branch, _) = branch_result
.map_err(|e| McpToolsError::Server(format!("Failed to process branch: {}", e)))?;
if let Some(name) = branch
.name()
.map_err(|e| McpToolsError::Server(format!("Failed to get branch name: {}", e)))?
{
let is_current = name == current_branch;
let last_commit = if let Some(oid) = branch.get().target() {
oid.to_string()[..8].to_string()
} else {
"unknown".to_string()
};
branch_infos.push(BranchInfo {
name: name.to_string(),
is_current,
is_remote: false,
last_commit,
ahead: 0, behind: 0,
});
}
}
Ok(branch_infos)
}
}
#[async_trait]
impl McpServerBase for GitToolsServer {
async fn get_capabilities(&self) -> Result<ServerCapabilities> {
let mut capabilities = self.base.get_capabilities().await?;
let git_tools = vec![
McpTool {
name: "git_status".to_string(),
description: "Get Git repository status including modified, added, deleted files"
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to Git repository (optional, defaults to current directory)"
}
}
}),
category: "git".to_string(),
requires_permission: false,
permissions: vec![],
},
McpTool {
name: "git_diff".to_string(),
description: "Get Git diff information for repository changes".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to Git repository (optional, defaults to current directory)"
},
"staged": {
"type": "string",
"description": "Show staged changes (true/false, default: false)"
}
}
}),
category: "git".to_string(),
requires_permission: false,
permissions: vec![],
},
McpTool {
name: "git_log".to_string(),
description: "Get Git commit history".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to Git repository (optional, defaults to current directory)"
},
"limit": {
"type": "string",
"description": "Number of commits to show (default: 10)"
},
"file_path": {
"type": "string",
"description": "Filter commits by specific file path (optional)"
}
}
}),
category: "git".to_string(),
requires_permission: false,
permissions: vec![],
},
McpTool {
name: "git_blame".to_string(),
description: "Get Git blame information for a file".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to Git repository (optional, defaults to current directory)"
},
"file_path": {
"type": "string",
"description": "Path to file for blame information"
}
},
"required": ["file_path"]
}),
category: "git".to_string(),
requires_permission: false,
permissions: vec![],
},
McpTool {
name: "git_branches".to_string(),
description: "Get Git branch information".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"repo_path": {
"type": "string",
"description": "Path to Git repository (optional, defaults to current directory)"
}
}
}),
category: "git".to_string(),
requires_permission: false,
permissions: vec![],
},
];
capabilities.tools = git_tools;
Ok(capabilities)
}
async fn handle_tool_request(&self, request: McpToolRequest) -> Result<McpToolResponse> {
info!("Handling Git tool request: {}", request.tool);
let repo_path = request
.arguments
.get("repo_path")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
match request.tool.as_str() {
"git_status" => {
debug!("Getting Git status for: {}", repo_path.display());
let status = self.get_status(&repo_path).await?;
let content_text = format!(
"Git Status for {}\n\
Branch: {}\n\
Ahead: {}, Behind: {}\n\
Modified: {}\n\
Added: {}\n\
Deleted: {}\n\
Untracked: {}\n\
Conflicted: {}",
repo_path.display(),
status.branch,
status.ahead,
status.behind,
status.modified.len(),
status.added.len(),
status.deleted.len(),
status.untracked.len(),
status.conflicted.len()
);
let mut metadata = HashMap::new();
metadata.insert("git_status".to_string(), serde_json::to_value(status)?);
Ok(McpToolResponse {
id: request.id,
content: vec![McpContent::text(content_text)],
is_error: false,
error: None,
metadata,
})
}
"git_diff" => {
debug!("Getting Git diff for: {}", repo_path.display());
let diff_infos = self.get_diff(&repo_path, &request.arguments).await?;
let content_text = format!(
"Git Diff for {}\n\
Files changed: {}",
repo_path.display(),
diff_infos.len()
);
let mut metadata = HashMap::new();
metadata.insert("git_diff".to_string(), serde_json::to_value(diff_infos)?);
Ok(McpToolResponse {
id: request.id,
content: vec![McpContent::text(content_text)],
is_error: false,
error: None,
metadata,
})
}
"git_log" => {
debug!("Getting Git log for: {}", repo_path.display());
let commits = self.get_log(&repo_path, &request.arguments).await?;
let content_text = format!(
"Git Log for {}\n\
Commits: {}",
repo_path.display(),
commits.len()
);
let mut metadata = HashMap::new();
metadata.insert("git_log".to_string(), serde_json::to_value(commits)?);
Ok(McpToolResponse {
id: request.id,
content: vec![McpContent::text(content_text)],
is_error: false,
error: None,
metadata,
})
}
"git_blame" => {
debug!("Getting Git blame for: {}", repo_path.display());
let blame_info = self.get_blame(&repo_path, &request.arguments).await?;
let content_text = format!(
"Git Blame for {}\n\
Lines: {}",
blame_info.file_path,
blame_info.lines.len()
);
let mut metadata = HashMap::new();
metadata.insert("git_blame".to_string(), serde_json::to_value(blame_info)?);
Ok(McpToolResponse {
id: request.id,
content: vec![McpContent::text(content_text)],
is_error: false,
error: None,
metadata,
})
}
"git_branches" => {
debug!("Getting Git branches for: {}", repo_path.display());
let branches = self.get_branches(&repo_path).await?;
let current = branches.iter().find(|b| b.is_current);
let content_text = format!(
"Git Branches for {}\n\
Total: {}\n\
Current: {}",
repo_path.display(),
branches.len(),
current.map(|b| b.name.as_str()).unwrap_or("None")
);
let mut metadata = HashMap::new();
metadata.insert("git_branches".to_string(), serde_json::to_value(branches)?);
Ok(McpToolResponse {
id: request.id,
content: vec![McpContent::text(content_text)],
is_error: false,
error: None,
metadata,
})
}
_ => {
warn!("Unknown Git tool: {}", request.tool);
Err(McpToolsError::Server(format!(
"Unknown Git tool: {}",
request.tool
)))
}
}
}
async fn get_stats(&self) -> Result<crate::common::ServerStats> {
self.base.get_stats().await
}
async fn initialize(&mut self) -> Result<()> {
info!("Initializing Git Tools MCP Server");
Ok(())
}
async fn shutdown(&mut self) -> Result<()> {
info!("Shutting down Git Tools MCP Server");
Ok(())
}
}