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#[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
143impl 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 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 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 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 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, "--", ¶ms.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 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#[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}