1use 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
22pub struct GitToolsServer {
24 base: BaseServer,
25}
26
27#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BlameInfo {
75 pub file_path: String,
76 pub lines: Vec<BlameLine>,
77}
78
79#[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#[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 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 async fn get_status(&self, repo_path: &Path) -> Result<GitStatus> {
118 let repo = self.get_repository(repo_path)?;
119
120 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 let (ahead, behind) = (0, 0); 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 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 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 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, deletions: 0,
221 hunks: Vec::new(), });
223 }
224 true
225 },
226 None,
227 None,
228 None,
229 )?;
230
231 Ok(diff_infos)
232 }
233
234 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 if let Some(file_path) = file_path {
276 debug!("Filtering by file path: {}", file_path);
278 }
279
280 let author = commit.author();
281 let files_changed = Vec::new(); 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 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 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 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, 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 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}