mcp-win-stdio-git 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1899 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unified Git & GitHub MCP Server for mcp-win-stdio.
4
+ Combines local Git repository management with remote GitHub CLI (gh) operations,
5
+ providing seamless repository switching, hybrid synergy workflows, token-safe
6
+ truncation, Windows CRLF filtering, and tiered fallback guidance.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ try:
19
+ from mcp.server.mcpserver import MCPServer as FastMCP
20
+ except (ImportError, ModuleNotFoundError):
21
+ from mcp.server.fastmcp import FastMCP
22
+
23
+ from mcp_win_stdio.core.config import INDALA_DIR, ensure_workspace_dirs
24
+
25
+ mcp = FastMCP("git-mcp")
26
+
27
+ # Persistent repository bookmarks
28
+ GIT_CONFIG_DIR = INDALA_DIR
29
+ REPOS_CONFIG_FILE = GIT_CONFIG_DIR / "git_repos.json"
30
+
31
+ _ACTIVE_REPO: Optional[Path] = None
32
+
33
+
34
+ # ==============================================================================
35
+ # Dependency Checkers & Tiered Guidance
36
+ # ==============================================================================
37
+
38
+ def is_git_installed() -> bool:
39
+ """Check if 'git' is accessible on PATH."""
40
+ return shutil.which("git") is not None
41
+
42
+
43
+ def is_gh_installed() -> bool:
44
+ """Check if 'gh' (GitHub CLI) is accessible on PATH."""
45
+ return shutil.which("gh") is not None
46
+
47
+
48
+ def get_git_missing_guidance() -> Dict[str, Any]:
49
+ """Actionable guidance when Git CLI is not found on Windows."""
50
+ return {
51
+ "status": "tool_unavailable",
52
+ "error": "Git CLI ('git') is not installed or not found on PATH.",
53
+ "install_guidance": {
54
+ "windows_winget": "winget install --id Git.Git -e",
55
+ "official_site": "https://git-scm.com/download/win",
56
+ "next_steps": [
57
+ "Open PowerShell or Windows Terminal.",
58
+ "Run: winget install --id Git.Git -e",
59
+ "Restart your terminal, Claude Desktop, or IDE.",
60
+ "Set author identity: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\""
61
+ ]
62
+ }
63
+ }
64
+
65
+
66
+ def get_gh_missing_guidance() -> Dict[str, Any]:
67
+ """Actionable guidance when GitHub CLI is not found on Windows."""
68
+ return {
69
+ "status": "tool_unavailable",
70
+ "error": "GitHub CLI ('gh') is not installed or not found on PATH.",
71
+ "install_guidance": {
72
+ "windows_winget": "winget install --id GitHub.cli -e",
73
+ "official_site": "https://cli.github.com",
74
+ "next_steps": [
75
+ "Open PowerShell or Windows Terminal.",
76
+ "Run: winget install --id GitHub.cli -e",
77
+ "Restart your terminal, Claude Desktop, or IDE.",
78
+ "Authenticate with GitHub: run 'gh auth login' and follow the browser prompt."
79
+ ]
80
+ },
81
+ "note": "All local Git tools remain 100% operational without GitHub CLI."
82
+ }
83
+
84
+
85
+ def get_gh_auth_missing_guidance() -> Dict[str, Any]:
86
+ """Actionable guidance when GitHub CLI is installed but not authenticated."""
87
+ return {
88
+ "status": "auth_required",
89
+ "error": "GitHub CLI ('gh') is installed, but not logged in to GitHub.",
90
+ "login_guidance": {
91
+ "command": "gh auth login",
92
+ "steps": [
93
+ "Open PowerShell or your terminal.",
94
+ "Run: gh auth login",
95
+ "Select 'GitHub.com', choose 'HTTPS', and log in via your web browser."
96
+ ]
97
+ },
98
+ "note": "All local Git tools remain 100% operational without GitHub authentication."
99
+ }
100
+
101
+
102
+ # ==============================================================================
103
+ # Subprocess Runners with Windows Safety & CRLF Sanitization
104
+ # ==============================================================================
105
+
106
+ def _filter_git_output(text: str) -> str:
107
+ """Filter out noisy Windows CRLF replacement warnings."""
108
+ if not text:
109
+ return ""
110
+ lines = text.splitlines()
111
+ filtered = [
112
+ ln for ln in lines
113
+ if "LF will be replaced by CRLF" not in ln
114
+ and "CRLF will be replaced by LF" not in ln
115
+ and "warning: in the working copy of" not in ln
116
+ ]
117
+ return "\n".join(filtered).strip()
118
+
119
+
120
+ def _truncate_output(text: str, max_lines: int = 250, max_chars: int = 15000) -> Dict[str, Any]:
121
+ """Protect LLM context window with token and line capping."""
122
+ lines = text.splitlines()
123
+ total_lines = len(lines)
124
+ truncated = False
125
+
126
+ if total_lines > max_lines:
127
+ lines = lines[:max_lines]
128
+ truncated = True
129
+
130
+ result_text = "\n".join(lines)
131
+ if len(result_text) > max_chars:
132
+ result_text = result_text[:max_chars]
133
+ truncated = True
134
+
135
+ payload: Dict[str, Any] = {
136
+ "content": result_text,
137
+ "total_lines": total_lines,
138
+ "returned_lines": len(lines),
139
+ "truncated": truncated
140
+ }
141
+ if truncated:
142
+ payload["notice"] = f"[Output capped: showing {len(lines)} of {total_lines} lines to protect context]"
143
+ return payload
144
+
145
+
146
+ def run_git_command(args: List[str], cwd: Path, timeout: int = 30) -> Dict[str, Any]:
147
+ """
148
+ Execute git command with non-interactive flags and timeout protection.
149
+ Prevents hangs on credentials, password prompts, or interactive editors.
150
+ """
151
+ if not is_git_installed():
152
+ return get_git_missing_guidance()
153
+
154
+ env = os.environ.copy()
155
+ env["GIT_TERMINAL_PROMPT"] = "0"
156
+ env["GIT_EDITOR"] = "true"
157
+ env["LC_ALL"] = "C.UTF-8"
158
+
159
+ cmd = ["git"] + args
160
+ try:
161
+ proc = subprocess.run(
162
+ cmd,
163
+ cwd=str(cwd),
164
+ env=env,
165
+ capture_output=True,
166
+ text=True,
167
+ encoding="utf-8",
168
+ errors="replace",
169
+ timeout=timeout
170
+ )
171
+ stdout = _filter_git_output(proc.stdout)
172
+ stderr = _filter_git_output(proc.stderr)
173
+
174
+ return {
175
+ "success": proc.returncode == 0,
176
+ "returncode": proc.returncode,
177
+ "stdout": stdout,
178
+ "stderr": stderr
179
+ }
180
+ except subprocess.TimeoutExpired:
181
+ return {
182
+ "success": False,
183
+ "error": f"Git command timed out after {timeout} seconds.",
184
+ "command": " ".join(cmd)
185
+ }
186
+ except Exception as e:
187
+ return {
188
+ "success": False,
189
+ "error": f"Failed to execute git command: {str(e)}",
190
+ "command": " ".join(cmd)
191
+ }
192
+
193
+
194
+ def run_gh_command(args: List[str], cwd: Path, timeout: int = 30) -> Dict[str, Any]:
195
+ """
196
+ Execute gh command with non-interactive flags and prompt suppression.
197
+ """
198
+ if not is_gh_installed():
199
+ return get_gh_missing_guidance()
200
+
201
+ env = os.environ.copy()
202
+ env["GH_NO_UPDATE_NOTIFIER"] = "1"
203
+ env["GH_PROMPT_DISABLED"] = "1"
204
+ env["LC_ALL"] = "C.UTF-8"
205
+
206
+ cmd = ["gh"] + args
207
+ try:
208
+ proc = subprocess.run(
209
+ cmd,
210
+ cwd=str(cwd),
211
+ env=env,
212
+ capture_output=True,
213
+ text=True,
214
+ encoding="utf-8",
215
+ errors="replace",
216
+ timeout=timeout
217
+ )
218
+ stdout = proc.stdout.strip()
219
+ stderr = proc.stderr.strip()
220
+
221
+ # Check for authentication errors
222
+ if proc.returncode != 0 and any(kw in stderr.lower() for kw in ("not logged in", "authentication required", "gh auth login")):
223
+ return get_gh_auth_missing_guidance()
224
+
225
+ return {
226
+ "success": proc.returncode == 0,
227
+ "returncode": proc.returncode,
228
+ "stdout": stdout,
229
+ "stderr": stderr
230
+ }
231
+ except subprocess.TimeoutExpired:
232
+ return {
233
+ "success": False,
234
+ "error": f"GitHub CLI command timed out after {timeout} seconds.",
235
+ "command": " ".join(cmd)
236
+ }
237
+ except Exception as e:
238
+ return {
239
+ "success": False,
240
+ "error": f"Failed to execute gh command: {str(e)}",
241
+ "command": " ".join(cmd)
242
+ }
243
+
244
+
245
+ # ==============================================================================
246
+ # Repository Context & Remote Auto-Detection
247
+ # ==============================================================================
248
+
249
+ def _load_repo_bookmarks() -> Dict[str, Any]:
250
+ ensure_workspace_dirs()
251
+ if REPOS_CONFIG_FILE.exists():
252
+ try:
253
+ with open(REPOS_CONFIG_FILE, "r", encoding="utf-8") as f:
254
+ return json.load(f)
255
+ except Exception:
256
+ return {"active": None, "repos": {}}
257
+ return {"active": None, "repos": {}}
258
+
259
+
260
+ def _save_repo_bookmarks(data: Dict[str, Any]) -> None:
261
+ ensure_workspace_dirs()
262
+ with open(REPOS_CONFIG_FILE, "w", encoding="utf-8") as f:
263
+ json.dump(data, f, indent=2)
264
+
265
+
266
+ def find_git_root(start_path: Optional[str] = None) -> Optional[Path]:
267
+ """Find closest git repository root climbing up from start_path or current dir."""
268
+ curr = Path(start_path).resolve() if start_path else Path.cwd().resolve()
269
+ for p in [curr] + list(curr.parents):
270
+ git_dir = p / ".git"
271
+ if git_dir.exists():
272
+ return p
273
+ return None
274
+
275
+
276
+ def resolve_repo_path(repo_path: Optional[str] = None) -> Path:
277
+ """Resolve active repository path using priority: explicit param -> active repo -> cwd search."""
278
+ global _ACTIVE_REPO
279
+
280
+ if repo_path:
281
+ p = Path(repo_path).resolve()
282
+ if (p / ".git").exists() or find_git_root(str(p)):
283
+ return find_git_root(str(p)) or p
284
+ return p
285
+
286
+ if _ACTIVE_REPO and (_ACTIVE_REPO / ".git").exists():
287
+ return _ACTIVE_REPO
288
+
289
+ # Check bookmarks config
290
+ bookmarks = _load_repo_bookmarks()
291
+ active_bookmark = bookmarks.get("active")
292
+ if active_bookmark and Path(active_bookmark).exists():
293
+ _ACTIVE_REPO = Path(active_bookmark).resolve()
294
+ return _ACTIVE_REPO
295
+
296
+ # Environment variable fallback
297
+ env_repo = os.environ.get("GIT_REPO_DIR")
298
+ if env_repo and Path(env_repo).exists():
299
+ _ACTIVE_REPO = Path(env_repo).resolve()
300
+ return _ACTIVE_REPO
301
+
302
+ # Upward climb from cwd
303
+ cwd_root = find_git_root()
304
+ if cwd_root:
305
+ _ACTIVE_REPO = cwd_root
306
+ return cwd_root
307
+
308
+ return Path.cwd().resolve()
309
+
310
+
311
+ def get_remote_github_info(cwd: Path) -> Optional[Dict[str, str]]:
312
+ """Extract GitHub owner and repository name from git remote origin URL."""
313
+ res = run_git_command(["remote", "get-url", "origin"], cwd=cwd)
314
+ if not res.get("success"):
315
+ return None
316
+ url = res.get("stdout", "").strip()
317
+ if not url:
318
+ return None
319
+
320
+ # Matches https://github.com/owner/repo(.git) or git@github.com:owner/repo(.git)
321
+ match = re.search(r"github\.com[/:](?P<owner>[\w\-]+)/(?P<repo>[\w\-\.]+?)(?:\.git)?$", url, re.IGNORECASE)
322
+ if match:
323
+ return {
324
+ "owner": match.group("owner"),
325
+ "repo": match.group("repo"),
326
+ "url": url
327
+ }
328
+ return {"url": url, "owner": "", "repo": ""}
329
+
330
+
331
+ # ==============================================================================
332
+ # 1. Repository Context & Bookmarks
333
+ # ==============================================================================
334
+
335
+ @mcp.tool()
336
+ def use_repo(repo_path: Optional[str] = None) -> Dict[str, Any]:
337
+ """
338
+ Switch or display the active Git repository workspace.
339
+ If repo_path is not specified, auto-detects from the current directory.
340
+ """
341
+ global _ACTIVE_REPO
342
+ if not is_git_installed():
343
+ return get_git_missing_guidance()
344
+
345
+ target = find_git_root(repo_path)
346
+ if not target or not (target / ".git").exists():
347
+ return {
348
+ "success": False,
349
+ "error": f"Directory '{repo_path or Path.cwd()}' is not a valid Git repository (no .git found)."
350
+ }
351
+
352
+ _ACTIVE_REPO = target
353
+ bookmarks = _load_repo_bookmarks()
354
+ bookmarks["active"] = str(target)
355
+ if "repos" not in bookmarks:
356
+ bookmarks["repos"] = {}
357
+ bookmarks["repos"][target.name] = str(target)
358
+ _save_repo_bookmarks(bookmarks)
359
+
360
+ remote_info = get_remote_github_info(target)
361
+ return {
362
+ "success": True,
363
+ "active_repo": str(target),
364
+ "name": target.name,
365
+ "remote_github": remote_info,
366
+ "message": f"Active repository set to: {target}"
367
+ }
368
+
369
+
370
+ @mcp.tool()
371
+ def list_repos() -> Dict[str, Any]:
372
+ """
373
+ List all registered repository bookmarks, active repository, and git status.
374
+ """
375
+ bookmarks = _load_repo_bookmarks()
376
+ active_path = str(_ACTIVE_REPO) if _ACTIVE_REPO else bookmarks.get("active")
377
+
378
+ repos_list = []
379
+ for alias, p_str in bookmarks.get("repos", {}).items():
380
+ exists = Path(p_str).exists()
381
+ is_git = (Path(p_str) / ".git").exists() if exists else False
382
+ repos_list.append({
383
+ "alias": alias,
384
+ "path": p_str,
385
+ "exists": exists,
386
+ "is_git": is_git,
387
+ "is_active": (p_str == active_path)
388
+ })
389
+
390
+ return {
391
+ "active_repo": active_path,
392
+ "bookmarks_count": len(repos_list),
393
+ "repositories": repos_list
394
+ }
395
+
396
+
397
+ @mcp.tool()
398
+ def add_repo(repo_path: str, alias: Optional[str] = None) -> Dict[str, Any]:
399
+ """
400
+ Bookmark a repository path for quick switching across multi-project workspaces.
401
+ """
402
+ p = find_git_root(repo_path)
403
+ if not p or not (p / ".git").exists():
404
+ return {
405
+ "success": False,
406
+ "error": f"Path '{repo_path}' is not a valid Git repository."
407
+ }
408
+
409
+ key = alias or p.name
410
+ bookmarks = _load_repo_bookmarks()
411
+ if "repos" not in bookmarks:
412
+ bookmarks["repos"] = {}
413
+ bookmarks["repos"][key] = str(p)
414
+ _save_repo_bookmarks(bookmarks)
415
+
416
+ return {
417
+ "success": True,
418
+ "alias": key,
419
+ "path": str(p),
420
+ "message": f"Repository '{key}' registered successfully."
421
+ }
422
+
423
+
424
+ # ==============================================================================
425
+ # 2. Hybrid Synergy Tools (Git + GH Combined)
426
+ # ==============================================================================
427
+
428
+ @mcp.tool()
429
+ def repo_overview(repo_path: Optional[str] = None) -> Dict[str, Any]:
430
+ """
431
+ Unified 360-degree repository status: local branch, dirty status, ahead/behind
432
+ tracking, open Pull Request for active branch, and latest CI/CD workflow runs.
433
+ Gracefully degrades if GitHub CLI ('gh') is not installed or not logged in.
434
+ """
435
+ if not is_git_installed():
436
+ return get_git_missing_guidance()
437
+
438
+ cwd = resolve_repo_path(repo_path)
439
+ if not (cwd / ".git").exists():
440
+ return {"success": False, "error": f"Not a git repository: {cwd}"}
441
+
442
+ # 1. Local Git Branch & Status
443
+ b_res = run_git_command(["branch", "--show-current"], cwd=cwd)
444
+ curr_branch = b_res.get("stdout", "").strip() or "HEAD (detached)"
445
+
446
+ s_res = run_git_command(["status", "--porcelain", "-b"], cwd=cwd)
447
+ status_lines = s_res.get("stdout", "").splitlines()
448
+ branch_header = status_lines[0] if status_lines else ""
449
+ file_changes = status_lines[1:] if len(status_lines) > 1 else []
450
+
451
+ # Tracking ahead/behind
452
+ ahead, behind = 0, 0
453
+ if "ahead" in branch_header:
454
+ m = re.search(r"ahead (\d+)", branch_header)
455
+ if m:
456
+ ahead = int(m.group(1))
457
+ if "behind" in branch_header:
458
+ m = re.search(r"behind (\d+)", branch_header)
459
+ if m:
460
+ behind = int(m.group(1))
461
+
462
+ # Conflict check
463
+ is_merging = (cwd / ".git" / "MERGE_HEAD").exists()
464
+ is_rebasing = (cwd / ".git" / "rebase-apply").exists() or (cwd / ".git" / "rebase-merge").exists()
465
+ is_cherry_picking = (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
466
+
467
+ overview: Dict[str, Any] = {
468
+ "repository_path": str(cwd),
469
+ "branch": curr_branch,
470
+ "tracking": branch_header.lstrip("## "),
471
+ "ahead": ahead,
472
+ "behind": behind,
473
+ "uncommitted_files_count": len(file_changes),
474
+ "is_dirty": len(file_changes) > 0,
475
+ "in_progress_state": {
476
+ "merging": is_merging,
477
+ "rebasing": is_rebasing,
478
+ "cherry_picking": is_cherry_picking
479
+ }
480
+ }
481
+
482
+ # 2. Remote GitHub Status (Graceful degradation)
483
+ remote_info = get_remote_github_info(cwd)
484
+ overview["remote"] = remote_info
485
+
486
+ if not is_gh_installed():
487
+ overview["github"] = {
488
+ "available": False,
489
+ "status": "gh_missing",
490
+ "message": "GitHub CLI ('gh') is not installed. Run 'winget install --id GitHub.cli -e' to view PRs and CI runs."
491
+ }
492
+ return overview
493
+
494
+ # Query active PR for this branch
495
+ pr_res = run_gh_command(["pr", "view", curr_branch, "--json", "number,title,state,url,mergeable,reviewDecision"], cwd=cwd)
496
+ active_pr = None
497
+ if pr_res.get("success"):
498
+ try:
499
+ active_pr = json.loads(pr_res.get("stdout", "{}"))
500
+ except Exception:
501
+ pass
502
+
503
+ # Query latest GitHub Actions CI run
504
+ run_res = run_gh_command(["run", "list", "--limit", "1", "--json", "databaseId,status,conclusion,name,headBranch,url"], cwd=cwd)
505
+ latest_run = None
506
+ if run_res.get("success"):
507
+ try:
508
+ runs = json.loads(run_res.get("stdout", "[]"))
509
+ if runs:
510
+ latest_run = runs[0]
511
+ except Exception:
512
+ pass
513
+
514
+ overview["github"] = {
515
+ "available": True,
516
+ "active_pull_request": active_pr,
517
+ "latest_ci_run": latest_run
518
+ }
519
+
520
+ return overview
521
+
522
+
523
+ @mcp.tool()
524
+ def pr_quickstart(
525
+ title: str,
526
+ body: Optional[str] = None,
527
+ branch: Optional[str] = None,
528
+ files: Optional[List[str]] = None,
529
+ draft: bool = False,
530
+ repo_path: Optional[str] = None
531
+ ) -> Dict[str, Any]:
532
+ """
533
+ Seamless Hybrid Workflow:
534
+ 1. Stages specified files (or all changes if files is omitted)
535
+ 2. Commits with the specified title/body
536
+ 3. Pushes branch to origin with upstream tracking
537
+ 4. Creates a GitHub Pull Request via 'gh pr create'
538
+ """
539
+ if not is_git_installed():
540
+ return get_git_missing_guidance()
541
+ if not is_gh_installed():
542
+ return get_gh_missing_guidance()
543
+
544
+ cwd = resolve_repo_path(repo_path)
545
+ if not (cwd / ".git").exists():
546
+ return {"success": False, "error": f"Not a git repository: {cwd}"}
547
+
548
+ # Step 1: Switch or verify branch
549
+ if branch:
550
+ run_git_command(["checkout", "-B", branch], cwd=cwd)
551
+ b_res = run_git_command(["branch", "--show-current"], cwd=cwd)
552
+ curr_branch = b_res.get("stdout", "").strip()
553
+
554
+ if curr_branch in ("main", "master") and not branch:
555
+ return {
556
+ "success": False,
557
+ "error": f"Cannot create PR directly from default branch '{curr_branch}'. Please specify a feature branch name."
558
+ }
559
+
560
+ # Step 2: Stage and Commit
561
+ if files:
562
+ run_git_command(["add"] + files, cwd=cwd)
563
+ else:
564
+ run_git_command(["add", "-A"], cwd=cwd)
565
+
566
+ # Check if anything to commit
567
+ st = run_git_command(["status", "--porcelain"], cwd=cwd)
568
+ if st.get("stdout", "").strip():
569
+ c_args = ["commit", "-m", title]
570
+ if body:
571
+ c_args += ["-m", body]
572
+ c_res = run_git_command(c_args, cwd=cwd)
573
+ if not c_res.get("success"):
574
+ return {"success": False, "step": "commit", "error": c_res.get("stderr") or c_res.get("stdout")}
575
+
576
+ # Step 3: Push to remote
577
+ p_res = run_git_command(["push", "-u", "origin", curr_branch], cwd=cwd)
578
+ if not p_res.get("success"):
579
+ return {"success": False, "step": "push", "error": p_res.get("stderr") or p_res.get("stdout")}
580
+
581
+ # Step 4: Create PR with GitHub CLI
582
+ gh_args = ["pr", "create", "--title", title, "--body", body or title]
583
+ if draft:
584
+ gh_args.append("--draft")
585
+
586
+ gh_res = run_gh_command(gh_args, cwd=cwd)
587
+ if not gh_res.get("success"):
588
+ return {
589
+ "success": False,
590
+ "step": "gh_pr_create",
591
+ "branch": curr_branch,
592
+ "message": "Pushed to remote successfully, but PR creation failed.",
593
+ "error": gh_res.get("stderr") or gh_res.get("stdout")
594
+ }
595
+
596
+ return {
597
+ "success": True,
598
+ "branch": curr_branch,
599
+ "pr_url": gh_res.get("stdout", "").strip(),
600
+ "message": f"Successfully committed, pushed, and opened PR on {curr_branch}."
601
+ }
602
+
603
+
604
+ @mcp.tool()
605
+ def issue_start_work(
606
+ issue_number: int,
607
+ branch_prefix: str = "feature",
608
+ repo_path: Optional[str] = None
609
+ ) -> Dict[str, Any]:
610
+ """
611
+ Issue-Driven Workflow:
612
+ Fetches GitHub Issue details via 'gh issue view', extracts title, creates a clean
613
+ local branch name (e.g. 'feature/42-fix-login-error'), and checks it out.
614
+ """
615
+ if not is_git_installed():
616
+ return get_git_missing_guidance()
617
+ if not is_gh_installed():
618
+ return get_gh_missing_guidance()
619
+
620
+ cwd = resolve_repo_path(repo_path)
621
+ # Fetch issue
622
+ i_res = run_gh_command(["issue", "view", str(issue_number), "--json", "number,title,labels,body,url"], cwd=cwd)
623
+ if not i_res.get("success"):
624
+ return {"success": False, "error": f"Failed to fetch issue #{issue_number}: {i_res.get('stderr')}"}
625
+
626
+ try:
627
+ issue = json.loads(i_res.get("stdout", "{}"))
628
+ except Exception as e:
629
+ return {"success": False, "error": f"Invalid JSON from gh issue view: {str(e)}"}
630
+
631
+ title = issue.get("title", f"issue-{issue_number}")
632
+ clean_title = re.sub(r"[^\w\s-]", "", title.lower())
633
+ slug = re.sub(r"[\s_-]+", "-", clean_title).strip("-")[:40]
634
+ branch_name = f"{branch_prefix}/{issue_number}-{slug}"
635
+
636
+ b_res = run_git_command(["checkout", "-b", branch_name], cwd=cwd)
637
+ if not b_res.get("success"):
638
+ return {"success": False, "error": f"Failed to create branch '{branch_name}': {b_res.get('stderr')}"}
639
+
640
+ return {
641
+ "success": True,
642
+ "issue": issue,
643
+ "created_branch": branch_name,
644
+ "message": f"Checked out local branch '{branch_name}' for issue #{issue_number}."
645
+ }
646
+
647
+
648
+ # ==============================================================================
649
+ # 3. Local Git Operations (14 Tools)
650
+ # ==============================================================================
651
+
652
+ @mcp.tool()
653
+ def git_status(repo_path: Optional[str] = None) -> Dict[str, Any]:
654
+ """
655
+ Structured git status showing staged, unstaged, untracked files, and in-progress states.
656
+ """
657
+ if not is_git_installed():
658
+ return get_git_missing_guidance()
659
+
660
+ cwd = resolve_repo_path(repo_path)
661
+ res = run_git_command(["status", "--porcelain=v1", "-b"], cwd=cwd)
662
+ if not res.get("success"):
663
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
664
+
665
+ lines = res.get("stdout", "").splitlines()
666
+ branch_line = lines[0] if lines else ""
667
+ staged, unstaged, untracked = [], [], []
668
+
669
+ for line in lines[1:]:
670
+ if len(line) < 3:
671
+ continue
672
+ index_code = line[0]
673
+ worktree_code = line[1]
674
+ file_path = line[3:].strip()
675
+
676
+ if index_code == "?" and worktree_code == "?":
677
+ untracked.append(file_path)
678
+ else:
679
+ if index_code not in (" ", "?"):
680
+ staged.append({"path": file_path, "status": index_code})
681
+ if worktree_code not in (" ", "?"):
682
+ unstaged.append({"path": file_path, "status": worktree_code})
683
+
684
+ return {
685
+ "success": True,
686
+ "branch": branch_line.lstrip("## "),
687
+ "clean": len(staged) == 0 and len(unstaged) == 0 and len(untracked) == 0,
688
+ "staged": staged,
689
+ "unstaged": unstaged,
690
+ "untracked": untracked,
691
+ "counts": {
692
+ "staged": len(staged),
693
+ "unstaged": len(unstaged),
694
+ "untracked": len(untracked)
695
+ }
696
+ }
697
+
698
+
699
+ @mcp.tool()
700
+ def git_diff(
701
+ staged: bool = False,
702
+ target: Optional[str] = None,
703
+ path: Optional[str] = None,
704
+ max_lines: int = 250,
705
+ repo_path: Optional[str] = None
706
+ ) -> Dict[str, Any]:
707
+ """
708
+ Token-safe git diff (unstaged changes, staged index diff, or between branches/commits).
709
+ """
710
+ if not is_git_installed():
711
+ return get_git_missing_guidance()
712
+
713
+ cwd = resolve_repo_path(repo_path)
714
+ args = ["diff"]
715
+ if staged:
716
+ args.append("--staged")
717
+ if target:
718
+ args.append(target)
719
+ if path:
720
+ args += ["--", path]
721
+
722
+ res = run_git_command(args, cwd=cwd)
723
+ if not res.get("success"):
724
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
725
+
726
+ raw_diff = res.get("stdout", "")
727
+ if not raw_diff:
728
+ return {"success": True, "diff": "", "message": "No diff found (working tree clean)."}
729
+
730
+ capped = _truncate_output(raw_diff, max_lines=max_lines)
731
+ return {
732
+ "success": True,
733
+ "staged": staged,
734
+ "target": target,
735
+ "diff": capped["content"],
736
+ "lines": capped["returned_lines"],
737
+ "truncated": capped["truncated"],
738
+ "notice": capped.get("notice")
739
+ }
740
+
741
+
742
+ @mcp.tool()
743
+ def git_log(
744
+ max_count: int = 20,
745
+ branch: Optional[str] = None,
746
+ path: Optional[str] = None,
747
+ repo_path: Optional[str] = None
748
+ ) -> Dict[str, Any]:
749
+ """
750
+ Structured commit history with hash, author, relative date, and subject.
751
+ Capped to prevent context window blowout.
752
+ """
753
+ if not is_git_installed():
754
+ return get_git_missing_guidance()
755
+
756
+ cwd = resolve_repo_path(repo_path)
757
+ limit = min(max_count, 100)
758
+ fmt = "%H%x1f%an%x1f%ae%x1f%cr%x1f%s"
759
+ args = ["log", f"-n{limit}", f"--format={fmt}"]
760
+ if branch:
761
+ args.append(branch)
762
+ if path:
763
+ args += ["--", path]
764
+
765
+ res = run_git_command(args, cwd=cwd)
766
+ if not res.get("success"):
767
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
768
+
769
+ commits = []
770
+ for line in res.get("stdout", "").splitlines():
771
+ parts = line.split("\x1f")
772
+ if len(parts) >= 5:
773
+ commits.append({
774
+ "commit": parts[0],
775
+ "short_hash": parts[0][:7],
776
+ "author": parts[1],
777
+ "email": parts[2],
778
+ "date": parts[3],
779
+ "subject": parts[4]
780
+ })
781
+
782
+ return {
783
+ "success": True,
784
+ "count": len(commits),
785
+ "commits": commits
786
+ }
787
+
788
+
789
+ @mcp.tool()
790
+ def git_commit(
791
+ message: str,
792
+ files: Optional[List[str]] = None,
793
+ all_modified: bool = False,
794
+ repo_path: Optional[str] = None
795
+ ) -> Dict[str, Any]:
796
+ """
797
+ Stage files and create a Git commit with a clean descriptive message.
798
+ """
799
+ if not is_git_installed():
800
+ return get_git_missing_guidance()
801
+
802
+ cwd = resolve_repo_path(repo_path)
803
+
804
+ # Check git author identity
805
+ name_res = run_git_command(["config", "user.name"], cwd=cwd)
806
+ email_res = run_git_command(["config", "user.email"], cwd=cwd)
807
+ if not name_res.get("stdout") or not email_res.get("stdout"):
808
+ return {
809
+ "success": False,
810
+ "error": "Git author identity is not configured.",
811
+ "guidance": "Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\""
812
+ }
813
+
814
+ # Staging
815
+ if files:
816
+ a_res = run_git_command(["add"] + files, cwd=cwd)
817
+ if not a_res.get("success"):
818
+ return {"success": False, "step": "stage", "error": a_res.get("stderr")}
819
+ elif all_modified:
820
+ run_git_command(["add", "-u"], cwd=cwd)
821
+
822
+ # Commit
823
+ res = run_git_command(["commit", "-m", message], cwd=cwd)
824
+ if not res.get("success"):
825
+ return {"success": False, "step": "commit", "error": res.get("stderr") or res.get("stdout")}
826
+
827
+ return {
828
+ "success": True,
829
+ "output": res.get("stdout"),
830
+ "message": "Commit created successfully."
831
+ }
832
+
833
+
834
+ @mcp.tool()
835
+ def git_branch(
836
+ action: str = "list",
837
+ branch_name: Optional[str] = None,
838
+ start_point: Optional[str] = None,
839
+ delete: bool = False,
840
+ force: bool = False,
841
+ repo_path: Optional[str] = None
842
+ ) -> Dict[str, Any]:
843
+ """
844
+ Branch operations: list branches, create, switch, or safely delete branches.
845
+ action: 'list', 'create', 'switch', 'delete'
846
+ """
847
+ if not is_git_installed():
848
+ return get_git_missing_guidance()
849
+
850
+ cwd = resolve_repo_path(repo_path)
851
+ act = action.lower()
852
+
853
+ if act == "list":
854
+ res = run_git_command(["branch", "-a", "--format=%(refname:short)|%(upstream:short)|%(HEAD)"], cwd=cwd)
855
+ branches = []
856
+ for line in res.get("stdout", "").splitlines():
857
+ parts = line.split("|")
858
+ if len(parts) >= 3:
859
+ branches.append({
860
+ "name": parts[0],
861
+ "upstream": parts[1] or None,
862
+ "is_current": parts[2] == "*"
863
+ })
864
+ return {"success": True, "branches": branches}
865
+
866
+ if act in ("create", "new"):
867
+ if not branch_name:
868
+ return {"success": False, "error": "branch_name is required for creating a branch."}
869
+ args = ["checkout", "-b", branch_name]
870
+ if start_point:
871
+ args.append(start_point)
872
+ res = run_git_command(args, cwd=cwd)
873
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
874
+
875
+ if act in ("switch", "checkout"):
876
+ if not branch_name:
877
+ return {"success": False, "error": "branch_name is required for switching branch."}
878
+ res = run_git_command(["checkout", branch_name], cwd=cwd)
879
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
880
+
881
+ if act == "delete" or delete:
882
+ if not branch_name:
883
+ return {"success": False, "error": "branch_name is required for deleting a branch."}
884
+ flag = "-D" if force else "-d"
885
+ res = run_git_command(["branch", flag, branch_name], cwd=cwd)
886
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
887
+
888
+ return {"success": False, "error": f"Unknown branch action: '{action}'. Use list, create, switch, or delete."}
889
+
890
+
891
+ @mcp.tool()
892
+ def git_stash(
893
+ action: str = "list",
894
+ message: Optional[str] = None,
895
+ index: int = 0,
896
+ repo_path: Optional[str] = None
897
+ ) -> Dict[str, Any]:
898
+ """
899
+ Manage the Git stash: 'save', 'pop', 'list', 'drop', 'clear'.
900
+ """
901
+ if not is_git_installed():
902
+ return get_git_missing_guidance()
903
+
904
+ cwd = resolve_repo_path(repo_path)
905
+ act = action.lower()
906
+
907
+ if act == "list":
908
+ res = run_git_command(["stash", "list"], cwd=cwd)
909
+ stashes = res.get("stdout", "").splitlines()
910
+ return {"success": True, "count": len(stashes), "stashes": stashes}
911
+
912
+ if act in ("save", "push"):
913
+ args = ["stash", "push"]
914
+ if message:
915
+ args += ["-m", message]
916
+ res = run_git_command(args, cwd=cwd)
917
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
918
+
919
+ if act == "pop":
920
+ res = run_git_command(["stash", "pop", f"stash@{{{index}}}"], cwd=cwd)
921
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
922
+
923
+ if act == "drop":
924
+ res = run_git_command(["stash", "drop", f"stash@{{{index}}}"], cwd=cwd)
925
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
926
+
927
+ return {"success": False, "error": f"Unknown stash action '{action}'. Use list, save, pop, or drop."}
928
+
929
+
930
+ @mcp.tool()
931
+ def git_sync(
932
+ action: str = "pull",
933
+ remote: str = "origin",
934
+ branch: Optional[str] = None,
935
+ set_upstream: bool = False,
936
+ repo_path: Optional[str] = None
937
+ ) -> Dict[str, Any]:
938
+ """
939
+ Synchronize with remote: 'fetch', 'pull', or 'push'.
940
+ """
941
+ if not is_git_installed():
942
+ return get_git_missing_guidance()
943
+
944
+ cwd = resolve_repo_path(repo_path)
945
+ act = action.lower()
946
+
947
+ if act == "fetch":
948
+ res = run_git_command(["fetch", remote], cwd=cwd)
949
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
950
+
951
+ if act == "pull":
952
+ args = ["pull", remote]
953
+ if branch:
954
+ args.append(branch)
955
+ res = run_git_command(args, cwd=cwd)
956
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
957
+
958
+ if act == "push":
959
+ args = ["push"]
960
+ if set_upstream:
961
+ args.append("-u")
962
+ args.append(remote)
963
+ if branch:
964
+ args.append(branch)
965
+ res = run_git_command(args, cwd=cwd)
966
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
967
+
968
+ return {"success": False, "error": f"Unknown sync action: '{action}'. Use fetch, pull, or push."}
969
+
970
+
971
+ @mcp.tool()
972
+ def git_blame(
973
+ file_path: str,
974
+ start_line: Optional[int] = None,
975
+ end_line: Optional[int] = None,
976
+ repo_path: Optional[str] = None
977
+ ) -> Dict[str, Any]:
978
+ """
979
+ View file blame line-by-line with author, date, and commit hash.
980
+ Supports line range filtering (start_line, end_line) to conserve tokens.
981
+ """
982
+ if not is_git_installed():
983
+ return get_git_missing_guidance()
984
+
985
+ cwd = resolve_repo_path(repo_path)
986
+ args = ["blame", "--show-email", "-s"]
987
+ if start_line is not None and end_line is not None:
988
+ args += [f"-L{start_line},{end_line}"]
989
+ elif start_line is not None:
990
+ args += [f"-L{start_line},+50"]
991
+ args += ["--", file_path]
992
+
993
+ res = run_git_command(args, cwd=cwd)
994
+ if not res.get("success"):
995
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
996
+
997
+ capped = _truncate_output(res.get("stdout", ""), max_lines=200)
998
+ return {
999
+ "success": True,
1000
+ "file": file_path,
1001
+ "blame": capped["content"],
1002
+ "lines": capped["returned_lines"],
1003
+ "truncated": capped["truncated"]
1004
+ }
1005
+
1006
+
1007
+ @mcp.tool()
1008
+ def git_reset(
1009
+ mode: str = "restore",
1010
+ target: Optional[str] = None,
1011
+ files: Optional[List[str]] = None,
1012
+ confirm_destructive: bool = False,
1013
+ repo_path: Optional[str] = None
1014
+ ) -> Dict[str, Any]:
1015
+ """
1016
+ Safe file restore or commit reset:
1017
+ - mode='restore': Safely discards unstaged changes in specific files (git restore <files>).
1018
+ - mode='soft': Reset commit history while keeping working directory and index intact.
1019
+ - mode='hard': Destructive reset. Requires confirm_destructive=True.
1020
+ """
1021
+ if not is_git_installed():
1022
+ return get_git_missing_guidance()
1023
+
1024
+ cwd = resolve_repo_path(repo_path)
1025
+ m = mode.lower()
1026
+
1027
+ if m == "restore":
1028
+ if not files:
1029
+ return {"success": False, "error": "Specify files to restore, or use confirm_destructive=True to restore all."}
1030
+ res = run_git_command(["restore"] + files, cwd=cwd)
1031
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr") or "Files restored."}
1032
+
1033
+ if m == "soft":
1034
+ tgt = target or "HEAD~1"
1035
+ res = run_git_command(["reset", "--soft", tgt], cwd=cwd)
1036
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1037
+
1038
+ if m == "hard":
1039
+ if not confirm_destructive:
1040
+ return {
1041
+ "success": False,
1042
+ "error": "Destructive operation blocked. 'git reset --hard' wipes uncommitted changes. Pass confirm_destructive=True to proceed."
1043
+ }
1044
+ tgt = target or "HEAD"
1045
+ res = run_git_command(["reset", "--hard", tgt], cwd=cwd)
1046
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1047
+
1048
+ return {"success": False, "error": f"Unknown reset mode '{mode}'. Use restore, soft, or hard."}
1049
+
1050
+
1051
+ @mcp.tool()
1052
+ def git_tag(
1053
+ action: str = "list",
1054
+ tag_name: Optional[str] = None,
1055
+ message: Optional[str] = None,
1056
+ delete: bool = False,
1057
+ repo_path: Optional[str] = None
1058
+ ) -> Dict[str, Any]:
1059
+ """
1060
+ List, create, or delete Git tags.
1061
+ """
1062
+ if not is_git_installed():
1063
+ return get_git_missing_guidance()
1064
+
1065
+ cwd = resolve_repo_path(repo_path)
1066
+ act = action.lower()
1067
+
1068
+ if act == "list":
1069
+ res = run_git_command(["tag", "-l", "--sort=-creatordate"], cwd=cwd)
1070
+ tags = res.get("stdout", "").splitlines()[:50]
1071
+ return {"success": True, "tags": tags, "count": len(tags)}
1072
+
1073
+ if act in ("create", "add"):
1074
+ if not tag_name:
1075
+ return {"success": False, "error": "tag_name is required to create a tag."}
1076
+ args = ["tag", tag_name]
1077
+ if message:
1078
+ args += ["-a", "-m", message]
1079
+ res = run_git_command(args, cwd=cwd)
1080
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1081
+
1082
+ if act == "delete" or delete:
1083
+ if not tag_name:
1084
+ return {"success": False, "error": "tag_name is required to delete a tag."}
1085
+ res = run_git_command(["tag", "-d", tag_name], cwd=cwd)
1086
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1087
+
1088
+ return {"success": False, "error": f"Unknown tag action: '{action}'. Use list, create, or delete."}
1089
+
1090
+
1091
+ @mcp.tool()
1092
+ def git_grep(
1093
+ pattern: str,
1094
+ path_spec: Optional[str] = None,
1095
+ ignore_case: bool = True,
1096
+ max_results: int = 50,
1097
+ repo_path: Optional[str] = None
1098
+ ) -> Dict[str, Any]:
1099
+ """
1100
+ Fast regex pattern search across tracked repository files at commit/branch level.
1101
+ """
1102
+ if not is_git_installed():
1103
+ return get_git_missing_guidance()
1104
+
1105
+ cwd = resolve_repo_path(repo_path)
1106
+ args = ["grep", "-n", "-E"]
1107
+ if ignore_case:
1108
+ args.append("-i")
1109
+ args.append(pattern)
1110
+ if path_spec:
1111
+ args += ["--", path_spec]
1112
+
1113
+ res = run_git_command(args, cwd=cwd)
1114
+ lines = res.get("stdout", "").splitlines()
1115
+ capped = lines[:max_results]
1116
+
1117
+ matches = []
1118
+ for ln in capped:
1119
+ parts = ln.split(":", 2)
1120
+ if len(parts) >= 3:
1121
+ matches.append({"file": parts[0], "line": parts[1], "match": parts[2]})
1122
+ else:
1123
+ matches.append({"raw": ln})
1124
+
1125
+ return {
1126
+ "success": True,
1127
+ "pattern": pattern,
1128
+ "total_found": len(lines),
1129
+ "returned": len(matches),
1130
+ "matches": matches,
1131
+ "truncated": len(lines) > max_results
1132
+ }
1133
+
1134
+
1135
+ @mcp.tool()
1136
+ def git_worktree(
1137
+ action: str = "list",
1138
+ path: Optional[str] = None,
1139
+ branch: Optional[str] = None,
1140
+ repo_path: Optional[str] = None
1141
+ ) -> Dict[str, Any]:
1142
+ """
1143
+ Manage Git worktrees: 'list', 'add', 'remove', 'prune'.
1144
+ Allows AI agents to work in isolated branches without disturbing user's open editor.
1145
+ """
1146
+ if not is_git_installed():
1147
+ return get_git_missing_guidance()
1148
+
1149
+ cwd = resolve_repo_path(repo_path)
1150
+ act = action.lower()
1151
+
1152
+ if act == "list":
1153
+ res = run_git_command(["worktree", "list"], cwd=cwd)
1154
+ lines = res.get("stdout", "").splitlines()
1155
+ worktrees = []
1156
+ for ln in lines:
1157
+ parts = ln.split()
1158
+ if len(parts) >= 2:
1159
+ worktrees.append({
1160
+ "path": parts[0],
1161
+ "commit": parts[1],
1162
+ "branch": parts[2].strip("[]") if len(parts) > 2 else "detached"
1163
+ })
1164
+ return {"success": True, "worktrees": worktrees}
1165
+
1166
+ if act == "add":
1167
+ if not path:
1168
+ return {"success": False, "error": "Worktree directory path is required."}
1169
+ args = ["worktree", "add", path]
1170
+ if branch:
1171
+ args.append(branch)
1172
+ res = run_git_command(args, cwd=cwd)
1173
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1174
+
1175
+ if act == "remove":
1176
+ if not path:
1177
+ return {"success": False, "error": "Worktree path is required to remove."}
1178
+ res = run_git_command(["worktree", "remove", path], cwd=cwd)
1179
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1180
+
1181
+ if act == "prune":
1182
+ res = run_git_command(["worktree", "prune"], cwd=cwd)
1183
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1184
+
1185
+ return {"success": False, "error": f"Unknown worktree action: '{action}'. Use list, add, remove, or prune."}
1186
+
1187
+
1188
+ @mcp.tool()
1189
+ def git_config(
1190
+ action: str = "get",
1191
+ key: Optional[str] = None,
1192
+ value: Optional[str] = None,
1193
+ scope: str = "global",
1194
+ repo_path: Optional[str] = None
1195
+ ) -> Dict[str, Any]:
1196
+ """
1197
+ Inspect or configure Git identity & settings (e.g. user.name, user.email, core.autocrlf).
1198
+ scope: 'global' or 'local'
1199
+ """
1200
+ if not is_git_installed():
1201
+ return get_git_missing_guidance()
1202
+
1203
+ cwd = resolve_repo_path(repo_path)
1204
+ act = action.lower()
1205
+ scope_flag = f"--{scope}"
1206
+
1207
+ if act == "get":
1208
+ if not key:
1209
+ res = run_git_command(["config", "--list", scope_flag], cwd=cwd)
1210
+ cfg = dict(line.split("=", 1) for line in res.get("stdout", "").splitlines() if "=" in line)
1211
+ return {"success": True, "scope": scope, "config": cfg}
1212
+ res = run_git_command(["config", scope_flag, key], cwd=cwd)
1213
+ return {"success": res.get("success"), "key": key, "value": res.get("stdout", "").strip()}
1214
+
1215
+ if act == "set":
1216
+ if not key or value is None:
1217
+ return {"success": False, "error": "Both key and value are required to set git config."}
1218
+ res = run_git_command(["config", scope_flag, key, value], cwd=cwd)
1219
+ return {"success": res.get("success"), "key": key, "value": value, "scope": scope}
1220
+
1221
+ return {"success": False, "error": f"Unknown config action: '{action}'. Use get or set."}
1222
+
1223
+
1224
+ @mcp.tool()
1225
+ def git_conflict_resolve(
1226
+ action: str = "status",
1227
+ mode: Optional[str] = None,
1228
+ repo_path: Optional[str] = None
1229
+ ) -> Dict[str, Any]:
1230
+ """
1231
+ Detect and manage in-progress merge/rebase/cherry-pick conflicts.
1232
+ action:
1233
+ - 'status': List all conflicted unmerged files
1234
+ - 'abort': Abort current merge or rebase (e.g. git merge --abort)
1235
+ - 'continue': Continue after resolving conflicts (git rebase --continue / git merge --continue)
1236
+ """
1237
+ if not is_git_installed():
1238
+ return get_git_missing_guidance()
1239
+
1240
+ cwd = resolve_repo_path(repo_path)
1241
+ is_merging = (cwd / ".git" / "MERGE_HEAD").exists()
1242
+ is_rebasing = (cwd / ".git" / "rebase-apply").exists() or (cwd / ".git" / "rebase-merge").exists()
1243
+ is_cherry_picking = (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
1244
+
1245
+ act = action.lower()
1246
+ if act == "status":
1247
+ res = run_git_command(["diff", "--name-only", "--diff-filter=U"], cwd=cwd)
1248
+ conflicted = res.get("stdout", "").splitlines()
1249
+ return {
1250
+ "success": True,
1251
+ "in_conflict": len(conflicted) > 0 or is_merging or is_rebasing or is_cherry_picking,
1252
+ "merging": is_merging,
1253
+ "rebasing": is_rebasing,
1254
+ "cherry_picking": is_cherry_picking,
1255
+ "conflicted_files": conflicted
1256
+ }
1257
+
1258
+ if act == "abort":
1259
+ cmd_type = "merge" if is_merging else ("rebase" if is_rebasing else "cherry-pick")
1260
+ res = run_git_command([cmd_type, "--abort"], cwd=cwd)
1261
+ return {"success": res.get("success"), "aborted": cmd_type, "output": res.get("stdout") or res.get("stderr")}
1262
+
1263
+ if act == "continue":
1264
+ cmd_type = "rebase" if is_rebasing else "merge"
1265
+ res = run_git_command([cmd_type, "--continue"], cwd=cwd)
1266
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1267
+
1268
+ return {"success": False, "error": f"Unknown action: '{action}'. Use status, abort, or continue."}
1269
+
1270
+
1271
+ # ==============================================================================
1272
+ # 4. Remote GitHub Operations via 'gh' CLI (10 Tools)
1273
+ # ==============================================================================
1274
+
1275
+ @mcp.tool()
1276
+ def gh_auth_status() -> Dict[str, Any]:
1277
+ """
1278
+ Check GitHub CLI authentication status, active account, and protocol scopes.
1279
+ """
1280
+ if not is_gh_installed():
1281
+ return get_gh_missing_guidance()
1282
+
1283
+ res = run_gh_command(["auth", "status"], cwd=resolve_repo_path())
1284
+ # gh auth status outputs to stderr by design
1285
+ out = res.get("stderr") or res.get("stdout")
1286
+ return {
1287
+ "success": res.get("success"),
1288
+ "raw_status": out,
1289
+ "is_logged_in": res.get("success") or "Logged in to" in out
1290
+ }
1291
+
1292
+
1293
+ @mcp.tool()
1294
+ def gh_switch_account(username: str) -> Dict[str, Any]:
1295
+ """
1296
+ Switch active GitHub CLI account if multiple accounts are configured.
1297
+ """
1298
+ if not is_gh_installed():
1299
+ return get_gh_missing_guidance()
1300
+
1301
+ res = run_gh_command(["auth", "switch", "--user", username], cwd=resolve_repo_path())
1302
+ return {
1303
+ "success": res.get("success"),
1304
+ "output": res.get("stdout") or res.get("stderr")
1305
+ }
1306
+
1307
+
1308
+ @mcp.tool()
1309
+ def gh_pr_list(
1310
+ state: str = "open",
1311
+ limit: int = 20,
1312
+ repo_path: Optional[str] = None
1313
+ ) -> Dict[str, Any]:
1314
+ """
1315
+ List pull requests for the repository (state: 'open', 'closed', 'merged', 'all').
1316
+ """
1317
+ if not is_gh_installed():
1318
+ return get_gh_missing_guidance()
1319
+
1320
+ cwd = resolve_repo_path(repo_path)
1321
+ lim = min(limit, 50)
1322
+ res = run_gh_command([
1323
+ "pr", "list",
1324
+ "--state", state,
1325
+ "--limit", str(lim),
1326
+ "--json", "number,title,state,headRefName,baseRefName,author,isDraft,url,updatedAt"
1327
+ ], cwd=cwd)
1328
+
1329
+ if not res.get("success"):
1330
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1331
+
1332
+ try:
1333
+ prs = json.loads(res.get("stdout", "[]"))
1334
+ return {"success": True, "count": len(prs), "pull_requests": prs}
1335
+ except Exception as e:
1336
+ return {"success": False, "error": f"Failed to parse PR list JSON: {str(e)}"}
1337
+
1338
+
1339
+ @mcp.tool()
1340
+ def gh_pr_view(
1341
+ pr_number: int,
1342
+ repo_path: Optional[str] = None
1343
+ ) -> Dict[str, Any]:
1344
+ """
1345
+ View complete details of a pull request (title, body, author, reviews, checks, mergeable status).
1346
+ """
1347
+ if not is_gh_installed():
1348
+ return get_gh_missing_guidance()
1349
+
1350
+ cwd = resolve_repo_path(repo_path)
1351
+ res = run_gh_command([
1352
+ "pr", "view", str(pr_number),
1353
+ "--json", "number,title,body,state,author,headRefName,baseRefName,mergeable,reviewDecision,statusCheckRollup,url"
1354
+ ], cwd=cwd)
1355
+
1356
+ if not res.get("success"):
1357
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1358
+
1359
+ try:
1360
+ pr_data = json.loads(res.get("stdout", "{}"))
1361
+ return {"success": True, "pull_request": pr_data}
1362
+ except Exception as e:
1363
+ return {"success": False, "error": f"Failed to parse PR JSON: {str(e)}"}
1364
+
1365
+
1366
+ @mcp.tool()
1367
+ def gh_pr_diff(
1368
+ pr_number: int,
1369
+ max_lines: int = 250,
1370
+ repo_path: Optional[str] = None
1371
+ ) -> Dict[str, Any]:
1372
+ """
1373
+ View token-capped diff of a remote GitHub Pull Request.
1374
+ """
1375
+ if not is_gh_installed():
1376
+ return get_gh_missing_guidance()
1377
+
1378
+ cwd = resolve_repo_path(repo_path)
1379
+ res = run_gh_command(["pr", "diff", str(pr_number)], cwd=cwd)
1380
+ if not res.get("success"):
1381
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1382
+
1383
+ capped = _truncate_output(res.get("stdout", ""), max_lines=max_lines)
1384
+ return {
1385
+ "success": True,
1386
+ "pr_number": pr_number,
1387
+ "diff": capped["content"],
1388
+ "lines": capped["returned_lines"],
1389
+ "truncated": capped["truncated"]
1390
+ }
1391
+
1392
+
1393
+ @mcp.tool()
1394
+ def gh_pr_checkout(
1395
+ pr_number: int,
1396
+ repo_path: Optional[str] = None
1397
+ ) -> Dict[str, Any]:
1398
+ """
1399
+ Check out a GitHub Pull Request branch locally in the Git repository.
1400
+ """
1401
+ if not is_gh_installed():
1402
+ return get_gh_missing_guidance()
1403
+
1404
+ cwd = resolve_repo_path(repo_path)
1405
+ res = run_gh_command(["pr", "checkout", str(pr_number)], cwd=cwd)
1406
+ return {
1407
+ "success": res.get("success"),
1408
+ "output": res.get("stdout") or res.get("stderr")
1409
+ }
1410
+
1411
+
1412
+ @mcp.tool()
1413
+ def gh_pr_action(
1414
+ pr_number: int,
1415
+ action: str,
1416
+ comment: Optional[str] = None,
1417
+ repo_path: Optional[str] = None
1418
+ ) -> Dict[str, Any]:
1419
+ """
1420
+ Perform PR lifecycle actions: 'approve', 'merge', 'close', 'reopen'.
1421
+ For 'merge', merges using default squash/merge settings.
1422
+ """
1423
+ if not is_gh_installed():
1424
+ return get_gh_missing_guidance()
1425
+
1426
+ cwd = resolve_repo_path(repo_path)
1427
+ act = action.lower()
1428
+
1429
+ if act == "approve":
1430
+ args = ["pr", "review", str(pr_number), "--approve"]
1431
+ if comment:
1432
+ args += ["--body", comment]
1433
+ res = run_gh_command(args, cwd=cwd)
1434
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1435
+
1436
+ if act == "merge":
1437
+ res = run_gh_command(["pr", "merge", str(pr_number), "--auto", "--merge"], cwd=cwd)
1438
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1439
+
1440
+ if act == "close":
1441
+ args = ["pr", "close", str(pr_number)]
1442
+ if comment:
1443
+ args += ["--comment", comment]
1444
+ res = run_gh_command(args, cwd=cwd)
1445
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1446
+
1447
+ if act == "reopen":
1448
+ res = run_gh_command(["pr", "reopen", str(pr_number)], cwd=cwd)
1449
+ return {"success": res.get("success"), "output": res.get("stdout") or res.get("stderr")}
1450
+
1451
+ return {"success": False, "error": f"Unknown PR action '{action}'. Use approve, merge, close, or reopen."}
1452
+
1453
+
1454
+ @mcp.tool()
1455
+ def gh_issue_list(
1456
+ state: str = "open",
1457
+ limit: int = 20,
1458
+ assignee: Optional[str] = None,
1459
+ labels: Optional[List[str]] = None,
1460
+ repo_path: Optional[str] = None
1461
+ ) -> Dict[str, Any]:
1462
+ """
1463
+ Search and list GitHub issues with filters (assignee, label, state).
1464
+ """
1465
+ if not is_gh_installed():
1466
+ return get_gh_missing_guidance()
1467
+
1468
+ cwd = resolve_repo_path(repo_path)
1469
+ args = [
1470
+ "issue", "list",
1471
+ "--state", state,
1472
+ "--limit", str(min(limit, 50)),
1473
+ "--json", "number,title,state,author,labels,assignees,url,updatedAt"
1474
+ ]
1475
+ if assignee:
1476
+ args += ["--assignee", assignee]
1477
+ if labels:
1478
+ for lbl in labels:
1479
+ args += ["--label", lbl]
1480
+
1481
+ res = run_gh_command(args, cwd=cwd)
1482
+ if not res.get("success"):
1483
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1484
+
1485
+ try:
1486
+ issues = json.loads(res.get("stdout", "[]"))
1487
+ return {"success": True, "count": len(issues), "issues": issues}
1488
+ except Exception as e:
1489
+ return {"success": False, "error": f"Failed to parse issues JSON: {str(e)}"}
1490
+
1491
+
1492
+ @mcp.tool()
1493
+ def gh_issue_view(
1494
+ issue_number: int,
1495
+ repo_path: Optional[str] = None
1496
+ ) -> Dict[str, Any]:
1497
+ """
1498
+ View complete GitHub issue details including body, labels, comments, and assignees.
1499
+ """
1500
+ if not is_gh_installed():
1501
+ return get_gh_missing_guidance()
1502
+
1503
+ cwd = resolve_repo_path(repo_path)
1504
+ res = run_gh_command([
1505
+ "issue", "view", str(issue_number),
1506
+ "--json", "number,title,body,state,author,labels,assignees,comments,url"
1507
+ ], cwd=cwd)
1508
+
1509
+ if not res.get("success"):
1510
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1511
+
1512
+ try:
1513
+ data = json.loads(res.get("stdout", "{}"))
1514
+ return {"success": True, "issue": data}
1515
+ except Exception as e:
1516
+ return {"success": False, "error": f"Failed to parse issue JSON: {str(e)}"}
1517
+
1518
+
1519
+ @mcp.tool()
1520
+ def gh_issue_create(
1521
+ title: str,
1522
+ body: str,
1523
+ labels: Optional[List[str]] = None,
1524
+ assignees: Optional[List[str]] = None,
1525
+ repo_path: Optional[str] = None
1526
+ ) -> Dict[str, Any]:
1527
+ """
1528
+ Create a new GitHub issue with title, description, labels, and assignees.
1529
+ """
1530
+ if not is_gh_installed():
1531
+ return get_gh_missing_guidance()
1532
+
1533
+ cwd = resolve_repo_path(repo_path)
1534
+ args = ["issue", "create", "--title", title, "--body", body]
1535
+ if labels:
1536
+ for lbl in labels:
1537
+ args += ["--label", lbl]
1538
+ if assignees:
1539
+ for a in assignees:
1540
+ args += ["--assignee", a]
1541
+
1542
+ res = run_gh_command(args, cwd=cwd)
1543
+ if not res.get("success"):
1544
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1545
+
1546
+ return {
1547
+ "success": True,
1548
+ "issue_url": res.get("stdout", "").strip(),
1549
+ "message": "Issue created successfully."
1550
+ }
1551
+
1552
+
1553
+ @mcp.tool()
1554
+ def gh_issue_comment(
1555
+ issue_number: int,
1556
+ comment: str,
1557
+ repo_path: Optional[str] = None
1558
+ ) -> Dict[str, Any]:
1559
+ """
1560
+ Post a comment on a GitHub issue or pull request.
1561
+ """
1562
+ if not is_gh_installed():
1563
+ return get_gh_missing_guidance()
1564
+
1565
+ cwd = resolve_repo_path(repo_path)
1566
+ res = run_gh_command(["issue", "comment", str(issue_number), "--body", comment], cwd=cwd)
1567
+ return {
1568
+ "success": res.get("success"),
1569
+ "output": res.get("stdout") or res.get("stderr")
1570
+ }
1571
+
1572
+
1573
+ @mcp.tool()
1574
+ def gh_run_status(
1575
+ limit: int = 10,
1576
+ repo_path: Optional[str] = None
1577
+ ) -> Dict[str, Any]:
1578
+ """
1579
+ Inspect GitHub Actions CI/CD workflows and recent run outcomes.
1580
+ """
1581
+ if not is_gh_installed():
1582
+ return get_gh_missing_guidance()
1583
+
1584
+ cwd = resolve_repo_path(repo_path)
1585
+ res = run_gh_command([
1586
+ "run", "list",
1587
+ "--limit", str(min(limit, 20)),
1588
+ "--json", "databaseId,name,status,conclusion,event,headBranch,url,createdAt"
1589
+ ], cwd=cwd)
1590
+
1591
+ if not res.get("success"):
1592
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1593
+
1594
+ try:
1595
+ runs = json.loads(res.get("stdout", "[]"))
1596
+ return {"success": True, "count": len(runs), "runs": runs}
1597
+ except Exception as e:
1598
+ return {"success": False, "error": f"Failed to parse runs JSON: {str(e)}"}
1599
+
1600
+
1601
+ @mcp.tool()
1602
+ def gh_gist_create(
1603
+ description: str,
1604
+ files: Dict[str, str],
1605
+ public: bool = False
1606
+ ) -> Dict[str, Any]:
1607
+ """
1608
+ Create a GitHub Gist with multiple files to share snippets, patches, or logs.
1609
+ """
1610
+ if not is_gh_installed():
1611
+ return get_gh_missing_guidance()
1612
+
1613
+ import tempfile
1614
+ with tempfile.TemporaryDirectory() as tmp_dir:
1615
+ tmp_path = Path(tmp_dir)
1616
+ file_args = []
1617
+ for fname, content in files.items():
1618
+ f_dest = tmp_path / fname
1619
+ f_dest.write_text(content, encoding="utf-8")
1620
+ file_args.append(str(f_dest))
1621
+
1622
+ args = ["gist", "create", "--desc", description] + file_args
1623
+ if public:
1624
+ args.append("--public")
1625
+
1626
+ res = run_gh_command(args, cwd=Path.cwd())
1627
+ if not res.get("success"):
1628
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1629
+
1630
+ return {
1631
+ "success": True,
1632
+ "gist_url": res.get("stdout", "").strip(),
1633
+ "message": "Gist created successfully."
1634
+ }
1635
+
1636
+
1637
+ @mcp.tool()
1638
+ def gh_search(
1639
+ kind: str = "issues",
1640
+ query: str = "",
1641
+ limit: int = 15,
1642
+ repo_path: Optional[str] = None
1643
+ ) -> Dict[str, Any]:
1644
+ """
1645
+ Search GitHub globally or within the current repository (kind: 'issues', 'prs', 'code', 'repos').
1646
+ """
1647
+ if not is_gh_installed():
1648
+ return get_gh_missing_guidance()
1649
+
1650
+ cwd = resolve_repo_path(repo_path)
1651
+ k = kind.lower()
1652
+ if k not in ("issues", "prs", "code", "repos"):
1653
+ return {"success": False, "error": f"Invalid search kind '{kind}'. Use issues, prs, code, or repos."}
1654
+
1655
+ args = ["search", k, query, "--limit", str(min(limit, 30))]
1656
+ res = run_gh_command(args, cwd=cwd)
1657
+ return {
1658
+ "success": res.get("success"),
1659
+ "results": res.get("stdout") or res.get("stderr")
1660
+ }
1661
+
1662
+
1663
+ @mcp.tool()
1664
+ def git_cherry_pick(
1665
+ commit_hash: str,
1666
+ no_commit: bool = False,
1667
+ repo_path: Optional[str] = None
1668
+ ) -> Dict[str, Any]:
1669
+ """
1670
+ [Advanced Tool] Apply the changes introduced by an existing commit to the current branch.
1671
+ Detects cherry-pick conflicts and provides clean resolution guidance.
1672
+
1673
+ Args:
1674
+ commit_hash: The SHA-1 hash or reference of the commit to cherry-pick.
1675
+ no_commit: Apply changes to working tree and index without creating a commit (default False).
1676
+ repo_path: Optional path to repository workspace.
1677
+ """
1678
+ if not is_git_installed():
1679
+ return get_git_missing_guidance()
1680
+
1681
+ cwd = resolve_repo_path(repo_path)
1682
+ args = ["cherry-pick"]
1683
+ if no_commit:
1684
+ args.append("-n")
1685
+ args.append(commit_hash)
1686
+
1687
+ res = run_git_command(args, cwd=cwd)
1688
+ if not res.get("success"):
1689
+ err = res.get("stderr") or res.get("stdout") or ""
1690
+ is_conflict = "conflict" in err.lower() or (cwd / ".git" / "CHERRY_PICK_HEAD").exists()
1691
+ return {
1692
+ "success": False,
1693
+ "commit_hash": commit_hash,
1694
+ "in_conflict": is_conflict,
1695
+ "error": err,
1696
+ "resolution": "Resolve conflicted files and run 'git_conflict_resolve(action=\"continue\")' or abort with 'git_conflict_resolve(action=\"abort\")'." if is_conflict else None
1697
+ }
1698
+
1699
+ return {
1700
+ "success": True,
1701
+ "commit_hash": commit_hash,
1702
+ "output": res.get("stdout") or "Commit cherry-picked successfully.",
1703
+ "message": f"Successfully cherry-picked {commit_hash} onto current branch."
1704
+ }
1705
+
1706
+
1707
+ @mcp.tool()
1708
+ def git_clean_untracked(
1709
+ dry_run: bool = True,
1710
+ remove_directories: bool = True,
1711
+ repo_path: Optional[str] = None
1712
+ ) -> Dict[str, Any]:
1713
+ """
1714
+ [Advanced Tool] Clean untracked files and build artifacts safely.
1715
+ Defaults to dry_run=True to prevent accidental loss of uncommitted work.
1716
+
1717
+ Args:
1718
+ dry_run: If True (default), only lists files that WOULD be removed without deleting anything.
1719
+ remove_directories: Also remove untracked directories (-d flag).
1720
+ repo_path: Optional path to repository workspace.
1721
+ """
1722
+ if not is_git_installed():
1723
+ return get_git_missing_guidance()
1724
+
1725
+ cwd = resolve_repo_path(repo_path)
1726
+ args = ["clean"]
1727
+ if dry_run:
1728
+ args.append("-n")
1729
+ else:
1730
+ args.append("-f")
1731
+
1732
+ if remove_directories:
1733
+ args.append("-d")
1734
+
1735
+ res = run_git_command(args, cwd=cwd)
1736
+ lines = res.get("stdout", "").splitlines()
1737
+ capped = lines[:100]
1738
+
1739
+ return {
1740
+ "success": res.get("success"),
1741
+ "dry_run": dry_run,
1742
+ "files_count": len(lines),
1743
+ "files": capped,
1744
+ "truncated": len(lines) > 100,
1745
+ "notice": "Dry run mode: no files were deleted. Pass dry_run=False to delete these files." if dry_run else f"Cleaned {len(lines)} untracked item(s)."
1746
+ }
1747
+
1748
+
1749
+ @mcp.tool()
1750
+ def gh_release_list(
1751
+ limit: int = 10,
1752
+ repo_path: Optional[str] = None
1753
+ ) -> Dict[str, Any]:
1754
+ """
1755
+ [Advanced Tool] List GitHub releases, tags, published dates, and release notes for the repository.
1756
+
1757
+ Args:
1758
+ limit: Maximum number of releases to return (default 10, max 30).
1759
+ repo_path: Optional repository workspace path.
1760
+ """
1761
+ if not is_gh_installed():
1762
+ return get_gh_missing_guidance()
1763
+
1764
+ cwd = resolve_repo_path(repo_path)
1765
+ lim = min(max(1, limit), 30)
1766
+ res = run_gh_command([
1767
+ "release", "list",
1768
+ "--limit", str(lim),
1769
+ "--json", "tagName,name,isDraft,isPrerelease,publishedAt,url"
1770
+ ], cwd=cwd)
1771
+
1772
+ if not res.get("success"):
1773
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1774
+
1775
+ try:
1776
+ releases = json.loads(res.get("stdout", "[]"))
1777
+ return {
1778
+ "success": True,
1779
+ "releases_count": len(releases),
1780
+ "releases": releases
1781
+ }
1782
+ except Exception as e:
1783
+ return {"success": False, "error": f"Failed to parse release JSON: {str(e)}"}
1784
+
1785
+
1786
+ @mcp.tool()
1787
+ def gh_pr_review_comments(
1788
+ pr_number: int,
1789
+ limit: int = 30,
1790
+ repo_path: Optional[str] = None
1791
+ ) -> Dict[str, Any]:
1792
+ """
1793
+ [Advanced Tool] Retrieve inline code review comments and discussion threads on a Pull Request.
1794
+
1795
+ Args:
1796
+ pr_number: Pull request number.
1797
+ limit: Maximum number of review comments to return (default 30, max 60).
1798
+ repo_path: Optional repository workspace path.
1799
+ """
1800
+ if not is_gh_installed():
1801
+ return get_gh_missing_guidance()
1802
+
1803
+ cwd = resolve_repo_path(repo_path)
1804
+ lim = min(max(1, limit), 60)
1805
+
1806
+ # Query PR review comments via gh api
1807
+ remote_info = get_remote_github_info(cwd)
1808
+ if not remote_info or not remote_info.get("owner") or not remote_info.get("repo"):
1809
+ return {"success": False, "error": "Could not determine GitHub owner/repo from git remote origin."}
1810
+
1811
+ owner = remote_info["owner"]
1812
+ repo = remote_info["repo"]
1813
+ endpoint = f"repos/{owner}/{repo}/pulls/{pr_number}/comments?per_page={lim}"
1814
+
1815
+ res = run_gh_command(["api", endpoint], cwd=cwd)
1816
+ if not res.get("success"):
1817
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1818
+
1819
+ try:
1820
+ raw_comments = json.loads(res.get("stdout", "[]"))
1821
+ cleaned_comments = []
1822
+ for c in raw_comments:
1823
+ cleaned_comments.append({
1824
+ "id": c.get("id"),
1825
+ "path": c.get("path"),
1826
+ "line": c.get("line") or c.get("original_line"),
1827
+ "author": c.get("user", {}).get("login"),
1828
+ "body": (c.get("body") or "")[:500],
1829
+ "created_at": c.get("created_at"),
1830
+ "url": c.get("html_url")
1831
+ })
1832
+
1833
+ return {
1834
+ "success": True,
1835
+ "pr_number": pr_number,
1836
+ "comments_count": len(cleaned_comments),
1837
+ "comments": cleaned_comments
1838
+ }
1839
+ except Exception as e:
1840
+ return {"success": False, "error": f"Failed to parse review comments: {str(e)}"}
1841
+
1842
+
1843
+ @mcp.tool()
1844
+ def gh_api(
1845
+ endpoint: str,
1846
+ method: str = "GET",
1847
+ fields: Optional[Dict[str, Any]] = None,
1848
+ max_chars: int = 15000,
1849
+ repo_path: Optional[str] = None
1850
+ ) -> Dict[str, Any]:
1851
+ """
1852
+ Direct GitHub REST/GraphQL API caller via 'gh api' with automatic context window protection.
1853
+
1854
+ Args:
1855
+ endpoint: GitHub API path (e.g. 'user', 'repos/:owner/:repo/commits').
1856
+ method: HTTP method (default 'GET').
1857
+ fields: Optional dictionary of query/body parameters.
1858
+ max_chars: Maximum character limit on response payload to preserve context (default 15000).
1859
+ repo_path: Optional repository workspace path.
1860
+ """
1861
+ if not is_gh_installed():
1862
+ return get_gh_missing_guidance()
1863
+
1864
+ cwd = resolve_repo_path(repo_path)
1865
+ args = ["api", endpoint, "-X", method.upper()]
1866
+ if fields:
1867
+ for k, v in fields.items():
1868
+ args += ["-F", f"{k}={v}"]
1869
+
1870
+ res = run_gh_command(args, cwd=cwd)
1871
+ if not res.get("success"):
1872
+ return {"success": False, "error": res.get("stderr") or res.get("stdout")}
1873
+
1874
+ raw_out = res.get("stdout", "")
1875
+ capped = _truncate_output(raw_out, max_lines=250, max_chars=max_chars)
1876
+
1877
+ try:
1878
+ data = json.loads(capped["content"]) if not capped["truncated"] else json.loads(raw_out[:max_chars])
1879
+ return {
1880
+ "success": True,
1881
+ "truncated": capped["truncated"],
1882
+ "response": data,
1883
+ "notice": capped.get("notice")
1884
+ }
1885
+ except Exception:
1886
+ return {
1887
+ "success": True,
1888
+ "truncated": capped["truncated"],
1889
+ "raw_response": capped["content"],
1890
+ "notice": capped.get("notice")
1891
+ }
1892
+
1893
+
1894
+ # ==============================================================================
1895
+ # Standalone execution
1896
+ # ==============================================================================
1897
+
1898
+ if __name__ == "__main__":
1899
+ mcp.run()