Skip to main content

mcp_git/
server.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use rmcp::handler::server::router::tool::ToolRouter;
5use rmcp::handler::server::wrapper::Parameters;
6use rmcp::model::*;
7use rmcp::{schemars, tool, tool_handler, tool_router, ServerHandler};
8use serde::Deserialize;
9
10use crate::error::McpGitError;
11use std::process::Command;
12
13#[derive(Clone)]
14pub struct RepoEntry {
15    pub name: String,
16    pub path: PathBuf,
17}
18
19#[derive(Clone)]
20pub struct McpGitServer {
21    repos: Arc<Vec<RepoEntry>>,
22    max_diff_lines: u32,
23    max_log_entries: u32,
24    tool_router: ToolRouter<Self>,
25}
26
27// -- Tool parameter types --
28
29#[derive(Debug, Deserialize, schemars::JsonSchema)]
30pub struct RepoParam {
31    #[schemars(description = "Repository name (optional if only one repo is connected)")]
32    #[serde(default)]
33    pub repo: Option<String>,
34}
35
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct LogParams {
38    #[schemars(description = "Repository name (optional if only one repo is connected)")]
39    #[serde(default)]
40    pub repo: Option<String>,
41
42    #[schemars(description = "Maximum number of commits to return")]
43    #[serde(default)]
44    pub max_count: Option<u32>,
45
46    #[schemars(description = "Branch or ref to show log for (default: HEAD)")]
47    #[serde(default)]
48    pub branch: Option<String>,
49
50    #[schemars(description = "Filter commits by author name or email")]
51    #[serde(default)]
52    pub author: Option<String>,
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct DiffParams {
57    #[schemars(description = "Repository name (optional if only one repo is connected)")]
58    #[serde(default)]
59    pub repo: Option<String>,
60
61    #[schemars(description = "Starting ref (commit SHA, branch, or tag)")]
62    pub from_ref: String,
63
64    #[schemars(description = "Ending ref (commit SHA, branch, or tag). Default: HEAD")]
65    #[serde(default)]
66    pub to_ref: Option<String>,
67
68    #[schemars(description = "Filter diff to a specific file path")]
69    #[serde(default)]
70    pub path: Option<String>,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct CommitParams {
75    #[schemars(description = "Repository name (optional if only one repo is connected)")]
76    #[serde(default)]
77    pub repo: Option<String>,
78
79    #[schemars(description = "Commit SHA or ref to show")]
80    pub commit: String,
81}
82
83#[derive(Debug, Deserialize, schemars::JsonSchema)]
84pub struct SearchParams {
85    #[schemars(description = "Repository name (optional if only one repo is connected)")]
86    #[serde(default)]
87    pub repo: Option<String>,
88
89    #[schemars(description = "Search query to match against commit messages")]
90    pub query: String,
91
92    #[schemars(description = "Maximum number of results to return")]
93    #[serde(default)]
94    pub max_count: Option<u32>,
95}
96
97#[derive(Debug, Deserialize, schemars::JsonSchema)]
98pub struct FileAtRefParams {
99    #[schemars(description = "Repository name (optional if only one repo is connected)")]
100    #[serde(default)]
101    pub repo: Option<String>,
102
103    #[schemars(description = "Path to the file within the repository")]
104    pub path: String,
105
106    #[schemars(description = "Git ref (commit SHA, branch, or tag). Default: HEAD")]
107    #[serde(default, rename = "ref")]
108    pub rev: Option<String>,
109}
110
111impl McpGitServer {
112    pub fn new(repos: Vec<RepoEntry>, max_diff_lines: u32, max_log_entries: u32) -> Self {
113        Self {
114            repos: Arc::new(repos),
115            max_diff_lines,
116            max_log_entries,
117            tool_router: Self::tool_router(),
118        }
119    }
120
121    fn resolve(&self, name: Option<&str>) -> Result<&RepoEntry, McpGitError> {
122        match name {
123            Some(n) => self
124                .repos
125                .iter()
126                .find(|r| r.name == n)
127                .ok_or_else(|| McpGitError::RepoNotFound(n.to_string())),
128            None if self.repos.len() == 1 => Ok(&self.repos[0]),
129            None => Err(McpGitError::AmbiguousRepo),
130        }
131    }
132
133    fn open_repo(&self, entry: &RepoEntry) -> Result<gix::Repository, McpGitError> {
134        gix::discover(&entry.path)
135            .map_err(|e| McpGitError::Git(format!("Cannot open repository '{}': {}", entry.name, e)))
136    }
137
138    fn err(&self, e: McpGitError) -> ErrorData {
139        e.to_mcp_error()
140    }
141}
142
143// -- Public methods for testability --
144
145impl McpGitServer {
146    pub fn do_list_repos(&self) -> Result<CallToolResult, ErrorData> {
147        let mut results = Vec::new();
148        for entry in self.repos.iter() {
149            let branch = match self.open_repo(entry) {
150                Ok(repo) => repo
151                    .head_name()
152                    .ok()
153                    .flatten()
154                    .map(|r| r.shorten().to_string())
155                    .unwrap_or_else(|| "detached".to_string()),
156                Err(_) => "unknown".to_string(),
157            };
158
159            results.push(serde_json::json!({
160                "name": entry.name,
161                "path": entry.path.display().to_string(),
162                "branch": branch,
163            }));
164        }
165
166        let text =
167            serde_json::to_string_pretty(&results).unwrap_or_else(|_| "[]".to_string());
168        Ok(CallToolResult::success(vec![Content::text(text)]))
169    }
170
171    pub fn do_log(&self, params: LogParams) -> Result<CallToolResult, ErrorData> {
172        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
173        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
174
175        let max = params.max_count.unwrap_or(self.max_log_entries);
176        let rev_spec = params.branch.as_deref().unwrap_or("HEAD");
177
178        let commit_id = repo
179            .rev_parse_single(gix::bstr::BStr::new(rev_spec.as_bytes()))
180            .map_err(|e| self.err(McpGitError::InvalidRef(format!("{}: {}", rev_spec, e))))?
181            .detach();
182
183        let mut commits = Vec::new();
184        let walk = repo
185            .rev_walk([commit_id])
186            .all()
187            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
188
189        for info in walk {
190            if commits.len() >= max as usize {
191                break;
192            }
193            let info = info.map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
194            let commit = info
195                .object()
196                .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
197
198            let author = commit.author().map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
199            let author_name = author.name.to_string();
200            let author_email = author.email.to_string();
201            let message = commit.message_raw_sloppy().to_string();
202            let time = author.time.seconds;
203
204            // Apply author filter if specified
205            if let Some(ref filter) = params.author {
206                let filter_lower = filter.to_lowercase();
207                if !author_name.to_lowercase().contains(&filter_lower)
208                    && !author_email.to_lowercase().contains(&filter_lower)
209                {
210                    continue;
211                }
212            }
213
214            commits.push(serde_json::json!({
215                "sha": commit.id().to_string(),
216                "author": format!("{} <{}>", author_name, author_email),
217                "timestamp": time,
218                "message": message.trim(),
219            }));
220        }
221
222        let text = serde_json::to_string_pretty(&serde_json::json!({
223            "commits": commits,
224            "count": commits.len(),
225        }))
226        .unwrap_or_else(|_| "{}".to_string());
227        Ok(CallToolResult::success(vec![Content::text(text)]))
228    }
229
230    pub fn do_diff(&self, params: DiffParams) -> Result<CallToolResult, ErrorData> {
231        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
232        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
233
234        let from = repo
235            .rev_parse_single(gix::bstr::BStr::new(params.from_ref.as_bytes()))
236            .map_err(|e| self.err(McpGitError::InvalidRef(format!("{}: {}", params.from_ref, e))))?;
237        let to_ref = params.to_ref.as_deref().unwrap_or("HEAD");
238        let to = repo
239            .rev_parse_single(gix::bstr::BStr::new(to_ref.as_bytes()))
240            .map_err(|e| self.err(McpGitError::InvalidRef(format!("{}: {}", to_ref, e))))?;
241
242        let from_commit = repo
243            .find_object(from)
244            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?
245            .try_into_commit()
246            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
247        let to_commit = repo
248            .find_object(to)
249            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?
250            .try_into_commit()
251            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
252
253        let from_tree = from_commit
254            .tree()
255            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
256        let to_tree = to_commit
257            .tree()
258            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
259
260        // Compute tree diff to find changed files
261        use gix::object::tree::diff::{Action as DiffAction, Change as DiffChange};
262        let mut changes = Vec::new();
263        let max_files = self.max_diff_lines as usize;
264
265        from_tree
266            .changes()
267            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?
268            .for_each_to_obtain_tree(&to_tree, |change: DiffChange<'_, '_, '_>| {
269                let path = change.location().to_string();
270
271                // Apply path filter if specified
272                if let Some(ref filter_path) = params.path {
273                    if !path.starts_with(filter_path.as_str()) {
274                        return Ok::<_, std::convert::Infallible>(DiffAction::Continue);
275                    }
276                }
277
278                let change_type = match &change {
279                    DiffChange::Addition { .. } => "added",
280                    DiffChange::Deletion { .. } => "deleted",
281                    DiffChange::Modification { .. } => "modified",
282                    DiffChange::Rewrite { copy: true, .. } => "copied",
283                    DiffChange::Rewrite { .. } => "renamed",
284                };
285
286                if changes.len() < max_files {
287                    changes.push(serde_json::json!({
288                        "path": path,
289                        "change": change_type,
290                    }));
291                }
292
293                Ok(DiffAction::Continue)
294            })
295            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
296
297        let text = serde_json::to_string_pretty(&serde_json::json!({
298            "from": params.from_ref,
299            "to": to_ref,
300            "from_sha": from_commit.id().to_string(),
301            "to_sha": to_commit.id().to_string(),
302            "files": changes,
303            "file_count": changes.len(),
304        }))
305        .unwrap_or_else(|_| "{}".to_string());
306        Ok(CallToolResult::success(vec![Content::text(text)]))
307    }
308
309    pub fn do_show_commit(&self, params: CommitParams) -> Result<CallToolResult, ErrorData> {
310        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
311        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
312
313        let id = repo
314            .rev_parse_single(gix::bstr::BStr::new(params.commit.as_bytes()))
315            .map_err(|e| self.err(McpGitError::InvalidRef(format!("{}: {}", params.commit, e))))?;
316
317        let commit = repo
318            .find_object(id)
319            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?
320            .try_into_commit()
321            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
322
323        let author = commit.author().map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
324        let committer = commit.committer().map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
325        let message = commit.message_raw_sloppy().to_string();
326        let time = author.time.seconds;
327
328        let parent_ids: Vec<String> = commit
329            .parent_ids()
330            .map(|id| id.to_string())
331            .collect();
332
333        let text = serde_json::to_string_pretty(&serde_json::json!({
334            "sha": commit.id().to_string(),
335            "author": format!("{} <{}>", author.name, author.email),
336            "committer": format!("{} <{}>", committer.name, committer.email),
337            "timestamp": time,
338            "message": message.trim(),
339            "parents": parent_ids,
340        }))
341        .unwrap_or_else(|_| "{}".to_string());
342        Ok(CallToolResult::success(vec![Content::text(text)]))
343    }
344
345    pub fn do_list_branches(&self, params: RepoParam) -> Result<CallToolResult, ErrorData> {
346        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
347        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
348
349        let head_name = repo
350            .head_name()
351            .ok()
352            .flatten()
353            .map(|r| r.shorten().to_string());
354
355        let platform = repo
356            .references()
357            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
358
359        let local = platform
360            .local_branches()
361            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
362
363        let mut branches = Vec::new();
364        for reference in local.flatten() {
365            let name = reference.name().shorten().to_string();
366            let is_current = head_name.as_deref() == Some(name.as_str());
367            branches.push(serde_json::json!({
368                "name": name,
369                "current": is_current,
370            }));
371        }
372
373        // Also list remote branches
374        let remote = platform
375            .remote_branches()
376            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
377
378        let mut remote_branches = Vec::new();
379        for reference in remote.flatten() {
380            let name = reference.name().shorten().to_string();
381            remote_branches.push(serde_json::json!({
382                "name": name,
383            }));
384        }
385
386        let text = serde_json::to_string_pretty(&serde_json::json!({
387            "local": branches,
388            "remote": remote_branches,
389        }))
390        .unwrap_or_else(|_| "{}".to_string());
391        Ok(CallToolResult::success(vec![Content::text(text)]))
392    }
393
394    pub fn do_search_commits(&self, params: SearchParams) -> Result<CallToolResult, ErrorData> {
395        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
396        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
397        let max = params.max_count.unwrap_or(self.max_log_entries);
398
399        let head = repo
400            .head_id()
401            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
402
403        let walk = repo
404            .rev_walk([head.detach()])
405            .all()
406            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
407
408        let query_lower = params.query.to_lowercase();
409        let mut matches = Vec::new();
410
411        for info in walk.flatten() {
412            let commit = match info.object() {
413                Ok(c) => c,
414                Err(_) => continue,
415            };
416
417            let message = commit.message_raw_sloppy().to_string();
418            if message.to_lowercase().contains(&query_lower) {
419                let author_str = match commit.author() {
420                    Ok(a) => format!("{} <{}>", a.name, a.email),
421                    Err(_) => "unknown".to_string(),
422                };
423                matches.push(serde_json::json!({
424                    "sha": commit.id().to_string(),
425                    "author": author_str,
426                    "message": message.trim(),
427                }));
428            }
429
430            if matches.len() >= max as usize {
431                break;
432            }
433        }
434
435        let text = serde_json::to_string_pretty(&serde_json::json!({
436            "query": params.query,
437            "matches": matches,
438            "count": matches.len(),
439        }))
440        .unwrap_or_else(|_| "{}".to_string());
441        Ok(CallToolResult::success(vec![Content::text(text)]))
442    }
443
444    pub fn do_status(&self, params: RepoParam) -> Result<CallToolResult, ErrorData> {
445        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
446
447        let output = Command::new("git")
448            .args(["status", "--porcelain=v1"])
449            .current_dir(&entry.path)
450            .output()
451            .map_err(|e| {
452                self.err(McpGitError::Git(format!(
453                    "Failed to run git status: {}",
454                    e
455                )))
456            })?;
457
458        if !output.status.success() {
459            return Err(self.err(McpGitError::Git(format!(
460                "git status failed: {}",
461                String::from_utf8_lossy(&output.stderr)
462            ))));
463        }
464
465        let stdout = String::from_utf8_lossy(&output.stdout);
466        let mut staged = Vec::new();
467        let mut unstaged = Vec::new();
468        let mut untracked = Vec::new();
469
470        for line in stdout.lines() {
471            if line.len() < 3 {
472                continue;
473            }
474            let bytes = line.as_bytes();
475            let index_status = bytes[0] as char;
476            let worktree_status = bytes[1] as char;
477            let path = &line[3..];
478
479            if index_status == '?' {
480                untracked.push(path.to_string());
481            } else {
482                if index_status != ' ' {
483                    staged.push(serde_json::json!({
484                        "path": path,
485                        "status": match index_status {
486                            'A' => "added",
487                            'M' => "modified",
488                            'D' => "deleted",
489                            'R' => "renamed",
490                            'C' => "copied",
491                            _ => "unknown",
492                        },
493                    }));
494                }
495                if worktree_status != ' ' {
496                    unstaged.push(serde_json::json!({
497                        "path": path,
498                        "status": match worktree_status {
499                            'M' => "modified",
500                            'D' => "deleted",
501                            _ => "unknown",
502                        },
503                    }));
504                }
505            }
506        }
507
508        let text = serde_json::to_string_pretty(&serde_json::json!({
509            "staged": staged,
510            "unstaged": unstaged,
511            "untracked": untracked,
512            "is_clean": staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
513        }))
514        .unwrap_or_else(|_| "{}".to_string());
515        Ok(CallToolResult::success(vec![Content::text(text)]))
516    }
517
518    pub fn do_get_file_contents(
519        &self,
520        params: FileAtRefParams,
521    ) -> Result<CallToolResult, ErrorData> {
522        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
523        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
524
525        let rev = params.rev.as_deref().unwrap_or("HEAD");
526        let spec = format!("{}:{}", rev, params.path);
527
528        let id = repo
529            .rev_parse_single(gix::bstr::BStr::new(spec.as_bytes()))
530            .map_err(|e| self.err(McpGitError::InvalidRef(format!("{}: {}", spec, e))))?;
531
532        let object = repo
533            .find_object(id)
534            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
535
536        let data = &object.data;
537        let is_binary = data.iter().take(8192).any(|&b| b == 0);
538
539        if is_binary {
540            let text = serde_json::to_string_pretty(&serde_json::json!({
541                "path": params.path,
542                "ref": rev,
543                "binary": true,
544                "size": data.len(),
545            }))
546            .unwrap_or_else(|_| "{}".to_string());
547            Ok(CallToolResult::success(vec![Content::text(text)]))
548        } else {
549            let content = String::from_utf8_lossy(data);
550            let text = serde_json::to_string_pretty(&serde_json::json!({
551                "path": params.path,
552                "ref": rev,
553                "content": content,
554                "size": data.len(),
555            }))
556            .unwrap_or_else(|_| "{}".to_string());
557            Ok(CallToolResult::success(vec![Content::text(text)]))
558        }
559    }
560
561    pub fn do_list_tags(&self, params: RepoParam) -> Result<CallToolResult, ErrorData> {
562        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
563        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
564
565        let platform = repo
566            .references()
567            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
568
569        let tag_refs = platform
570            .tags()
571            .map_err(|e| self.err(McpGitError::Git(e.to_string())))?;
572
573        let mut tags = Vec::new();
574        for mut reference in tag_refs.flatten() {
575            let name = reference.name().shorten().to_string();
576            let sha = reference
577                .peel_to_id_in_place()
578                .map(|id| id.to_string())
579                .unwrap_or_else(|_| "unknown".to_string());
580
581            tags.push(serde_json::json!({
582                "name": name,
583                "sha": sha,
584            }));
585        }
586
587        let text = serde_json::to_string_pretty(&serde_json::json!({
588            "tags": tags,
589            "count": tags.len(),
590        }))
591        .unwrap_or_else(|_| "{}".to_string());
592        Ok(CallToolResult::success(vec![Content::text(text)]))
593    }
594
595    pub fn do_get_remote_info(&self, params: RepoParam) -> Result<CallToolResult, ErrorData> {
596        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
597        let repo = self.open_repo(entry).map_err(|e| self.err(e))?;
598
599        let names = repo.remote_names();
600        let mut remotes = Vec::new();
601
602        for name in &names {
603            match repo.find_remote(name.as_ref()) {
604                Ok(remote) => {
605                    let fetch_url = remote
606                        .url(gix::remote::Direction::Fetch)
607                        .map(|u| u.to_bstring().to_string())
608                        .unwrap_or_default();
609                    let push_url = remote
610                        .url(gix::remote::Direction::Push)
611                        .map(|u| u.to_bstring().to_string())
612                        .unwrap_or_default();
613
614                    remotes.push(serde_json::json!({
615                        "name": name.to_string(),
616                        "fetch_url": fetch_url,
617                        "push_url": push_url,
618                    }));
619                }
620                Err(_) => continue,
621            }
622        }
623
624        let text = serde_json::to_string_pretty(&serde_json::json!({
625            "remotes": remotes,
626            "count": remotes.len(),
627        }))
628        .unwrap_or_else(|_| "{}".to_string());
629        Ok(CallToolResult::success(vec![Content::text(text)]))
630    }
631
632    pub fn do_blame(&self, params: FileAtRefParams) -> Result<CallToolResult, ErrorData> {
633        let entry = self.resolve(params.repo.as_deref()).map_err(|e| self.err(e))?;
634        let rev = params.rev.as_deref().unwrap_or("HEAD");
635
636        let output = Command::new("git")
637            .args(["blame", "--line-porcelain", rev, "--", &params.path])
638            .current_dir(&entry.path)
639            .output()
640            .map_err(|e| {
641                self.err(McpGitError::Git(format!(
642                    "Failed to run git blame: {}",
643                    e
644                )))
645            })?;
646
647        if !output.status.success() {
648            return Err(self.err(McpGitError::Git(format!(
649                "git blame failed: {}",
650                String::from_utf8_lossy(&output.stderr)
651            ))));
652        }
653
654        let stdout = String::from_utf8_lossy(&output.stdout);
655
656        struct BlameLine {
657            sha: String,
658            author: String,
659            line_no: u32,
660        }
661
662        let mut lines: Vec<BlameLine> = Vec::new();
663        let mut sha = String::new();
664        let mut author = String::new();
665        let mut line_no = 0u32;
666
667        for raw in stdout.lines() {
668            if raw.starts_with('\t') {
669                lines.push(BlameLine {
670                    sha: sha.clone(),
671                    author: author.clone(),
672                    line_no,
673                });
674                continue;
675            }
676
677            if raw.len() > 40 && raw.as_bytes()[40] == b' ' {
678                let maybe_sha = &raw[..40];
679                if maybe_sha.chars().all(|c| c.is_ascii_hexdigit()) {
680                    sha = maybe_sha.to_string();
681                    let rest: Vec<&str> = raw[41..].splitn(3, ' ').collect();
682                    if rest.len() >= 2 {
683                        line_no = rest[1].parse().unwrap_or(0);
684                    }
685                    continue;
686                }
687            }
688
689            if let Some(a) = raw.strip_prefix("author ") {
690                author = a.to_string();
691            }
692        }
693
694        // Group consecutive lines by same commit
695        let mut groups = Vec::new();
696        let mut i = 0;
697        while i < lines.len() {
698            let start = lines[i].line_no;
699            let group_sha = lines[i].sha.clone();
700            let group_author = lines[i].author.clone();
701            let mut end = start;
702            i += 1;
703
704            while i < lines.len() && lines[i].sha == group_sha {
705                end = lines[i].line_no;
706                i += 1;
707            }
708
709            let line_range = if start == end {
710                format!("{}", start)
711            } else {
712                format!("{}-{}", start, end)
713            };
714
715            groups.push(serde_json::json!({
716                "commit": &group_sha[..std::cmp::min(8, group_sha.len())],
717                "author": group_author,
718                "lines": line_range,
719            }));
720        }
721
722        let text = serde_json::to_string_pretty(&serde_json::json!({
723            "path": params.path,
724            "ref": rev,
725            "blame": groups,
726            "total_lines": lines.len(),
727        }))
728        .unwrap_or_else(|_| "{}".to_string());
729        Ok(CallToolResult::success(vec![Content::text(text)]))
730    }
731}
732
733// -- MCP tool handlers (thin wrappers) --
734
735#[tool_router]
736impl McpGitServer {
737    #[tool(
738        name = "list_repos",
739        description = "List all connected Git repositories with their paths and current branch"
740    )]
741    async fn list_repos(&self) -> Result<CallToolResult, ErrorData> {
742        self.do_list_repos()
743    }
744
745    #[tool(
746        name = "log",
747        description = "Show commit history for a repository. Returns commit SHA, author, date, and message."
748    )]
749    async fn log(
750        &self,
751        Parameters(params): Parameters<LogParams>,
752    ) -> Result<CallToolResult, ErrorData> {
753        self.do_log(params)
754    }
755
756    #[tool(
757        name = "diff",
758        description = "Show the diff between two refs (commits, branches, or tags)"
759    )]
760    async fn diff(
761        &self,
762        Parameters(params): Parameters<DiffParams>,
763    ) -> Result<CallToolResult, ErrorData> {
764        self.do_diff(params)
765    }
766
767    #[tool(
768        name = "show_commit",
769        description = "Show details of a specific commit including message, author, date, and files changed"
770    )]
771    async fn show_commit(
772        &self,
773        Parameters(params): Parameters<CommitParams>,
774    ) -> Result<CallToolResult, ErrorData> {
775        self.do_show_commit(params)
776    }
777
778    #[tool(
779        name = "list_branches",
780        description = "List all branches in the repository with current branch marked"
781    )]
782    async fn list_branches(
783        &self,
784        Parameters(params): Parameters<RepoParam>,
785    ) -> Result<CallToolResult, ErrorData> {
786        self.do_list_branches(params)
787    }
788
789    #[tool(
790        name = "search_commits",
791        description = "Search commit messages for a given query string"
792    )]
793    async fn search_commits(
794        &self,
795        Parameters(params): Parameters<SearchParams>,
796    ) -> Result<CallToolResult, ErrorData> {
797        self.do_search_commits(params)
798    }
799
800    #[tool(
801        name = "status",
802        description = "Show working tree status including staged, unstaged, and untracked files"
803    )]
804    async fn status(
805        &self,
806        Parameters(params): Parameters<RepoParam>,
807    ) -> Result<CallToolResult, ErrorData> {
808        self.do_status(params)
809    }
810
811    #[tool(
812        name = "get_file_contents",
813        description = "Get the content of a file at a specific Git revision"
814    )]
815    async fn get_file_contents(
816        &self,
817        Parameters(params): Parameters<FileAtRefParams>,
818    ) -> Result<CallToolResult, ErrorData> {
819        self.do_get_file_contents(params)
820    }
821
822    #[tool(
823        name = "list_tags",
824        description = "List all tags in the repository with their commit SHAs"
825    )]
826    async fn list_tags(
827        &self,
828        Parameters(params): Parameters<RepoParam>,
829    ) -> Result<CallToolResult, ErrorData> {
830        self.do_list_tags(params)
831    }
832
833    #[tool(
834        name = "get_remote_info",
835        description = "List configured Git remotes with their fetch and push URLs"
836    )]
837    async fn get_remote_info(
838        &self,
839        Parameters(params): Parameters<RepoParam>,
840    ) -> Result<CallToolResult, ErrorData> {
841        self.do_get_remote_info(params)
842    }
843
844    #[tool(
845        name = "blame",
846        description = "Show line-by-line authorship for a file, grouped by commit"
847    )]
848    async fn blame(
849        &self,
850        Parameters(params): Parameters<FileAtRefParams>,
851    ) -> Result<CallToolResult, ErrorData> {
852        self.do_blame(params)
853    }
854}
855
856#[tool_handler]
857impl ServerHandler for McpGitServer {
858    fn get_info(&self) -> ServerInfo {
859        ServerInfo {
860            protocol_version: ProtocolVersion::V_2024_11_05,
861            capabilities: ServerCapabilities::builder().enable_tools().build(),
862            server_info: Implementation {
863                name: "mcp-git".to_string(),
864                version: env!("CARGO_PKG_VERSION").to_string(),
865                ..Default::default()
866            },
867            instructions: Some(
868                "Git repository server. Tools: list_repos (connected repos), log (commit history), \
869                 diff (compare refs), show_commit (commit details), list_branches (branches), \
870                 search_commits (search messages), status (working tree status), \
871                 get_file_contents (file at revision), list_tags (tags), \
872                 get_remote_info (remotes), blame (line authorship)."
873                    .to_string(),
874            ),
875        }
876    }
877}