#!/usr/bin/env python3 """MCP server wrapping git plumbing for the auto-agents pipeline. Replaces the fleet of single-op subagents in ``.opencode/agents/`` — ``git-isolator-util``, ``git-clone-util``, ``git-fetch-util``, ``git-checkout-util``, ``git-stage-util``, ``git-commit-util``, ``git-create-commit-util``, ``git-commit-and-push-util``, ``git-push-util``, ``git-rebase-util``, ``git-rebase-and-push-util``, ``git-force-push-with-lease-util``, ``git-cleanup-util``. That layer existed only as a permission-scoping pattern (each agent's narrow bash allowlist covered exactly one git operation). With this MCP, agents call typed tools — no subagent overhead, no per-op prompt boilerplate, and the worktree-path allowlist lives in one file instead of being scattered across 13. Path safety ----------- Every tool that takes a ``worktree`` argument validates the path against an allowlist of acceptable bases — currently ``/tmp/cleveragents-implementer-worktrees/`` and ``/tmp/cleveragents-review-worktrees/``. A request to operate on ``/etc``, ``/`` or the host repo is rejected with a clear error before any git invocation runs. The allowlist can be extended via ``MCP_GIT_WORKTREE_BASES`` (colon-separated paths) for edge cases like local development or future dispatchers. Identity -------- Pushes authenticate as HAL9000 (``FORGEJO_PAT``) — the worker identity for code changes. The reviewer never commits or pushes under normal operation; if a future workflow needs it, add an explicit ``as_reviewer=True`` parameter to ``push`` rather than flipping a runtime toggle. Author/committer identity for :func:`commit` comes from ``GIT_USER_NAME`` / ``GIT_USER_EMAIL`` which ``tools/launch_fork.sh`` sets to ``CleverThis`` / ``hal9000@cleverthis.com``. Tools ----- ``isolate(pr, head_sha, head_ref=None, kind="implementer")`` Pre-clone a PR's head SHA into a fresh worktree under the kind-specific base. Wraps ``_pr_clone.prepare_pr_worktree``. Returns ``{worktree, branch, kind, run_tag}`` or ``{error: str}``. ``status(worktree)`` / ``stage(worktree, paths)`` / ``commit(worktree, message, author_name?, author_email?)`` / ``push(worktree, remote="origin", force_with_lease=False)`` / ``fetch(worktree, remote="origin")`` / ``rebase(worktree, onto)`` / ``cleanup(worktree)`` Standard git plumbing — see each tool's docstring for shape. Each write tool returns a small structured dict with the next-step fact the agent needs (e.g. ``commit`` returns ``{sha}``, ``push`` returns ``{remote_sha}``, ``rebase`` returns ``{success, conflicts}``). On failure the tool returns ``{error: str, stderr: str}`` so the agent can surface the real git error without re-reading transcript output. """ from __future__ import annotations import os import shutil import subprocess import sys import tempfile import uuid from pathlib import Path from typing import Any from mcp.server.fastmcp import FastMCP sys.path.insert(0, str(Path(__file__).parent)) from _mcp_common import ( # noqa: E402 ForgejoCfg, bootstrap_loader, error_envelope as _err, make_main, require_token, ) load_sibling = bootstrap_loader() _pr_clone = load_sibling("_pr_clone", "_pr_clone.py") server = FastMCP("git") # ─── Worktree allowlist ──────────────────────────────────────────── def _allowed_bases() -> tuple[Path, ...]: """Resolve the set of base directories any ``worktree`` argument is allowed to live under. The defaults match the dispatcher's pre-clone targets; the env var lets operators extend (e.g. for a one-off debug worktree elsewhere).""" defaults = ( "/tmp/cleveragents-implementer-worktrees", "/tmp/cleveragents-review-worktrees", ) extra = os.environ.get("MCP_GIT_WORKTREE_BASES", "").strip() extras = tuple(p for p in extra.split(":") if p) if extra else () return tuple(Path(p).resolve() for p in (defaults + extras)) def _validate_worktree(worktree: str) -> tuple[Path | None, str | None]: """Return ``(resolved_path, None)`` if ``worktree`` is allowed, or ``(None, error_message)`` if not. Resolves symlinks so an agent cannot escape the allowlist by linking through /tmp.""" if not worktree or not worktree.strip(): return None, "worktree path must be non-empty" try: resolved = Path(worktree).expanduser().resolve() except (OSError, RuntimeError) as exc: return None, f"could not resolve {worktree!r}: {exc}" if not resolved.exists(): return None, f"worktree does not exist at {resolved}" if not resolved.is_dir(): return None, f"worktree is not a directory: {resolved}" for base in _allowed_bases(): try: resolved.relative_to(base) return resolved, None except ValueError: continue bases = ", ".join(str(b) for b in _allowed_bases()) return None, ( f"worktree {resolved} is outside the allowed bases ({bases}). " "Set MCP_GIT_WORKTREE_BASES to extend if this is intentional." ) # ─── Subprocess wrapping ─────────────────────────────────────────── _DEFAULT_GIT_TIMEOUT_S = int(os.environ.get("MCP_GIT_TIMEOUT_S", "300")) def _ensure_askpass_for_hal9000() -> Path: """Create a 0700 askpass shim that returns the HAL9000 PAT. Mirrors the pattern in ``_pr_clone_creds._ensure_askpass_script`` but pins to ``FORGEJO_PAT`` (HAL9000) instead of ``FORGEJO_REVIEWER_PAT`` (HAL9001). The MCP server is per-process long-lived, so the script is created lazily on first push/fetch and reused thereafter. """ global _ASKPASS_PATH if _ASKPASS_PATH is not None and _ASKPASS_PATH.exists(): return _ASKPASS_PATH fd, path = tempfile.mkstemp(prefix="mcp-git-askpass-", suffix=".sh") with os.fdopen(fd, "w") as f: f.write( "#!/bin/sh\n" "# Generated by tools/mcp_git_server.py — HAL9000 (FORGEJO_PAT).\n" 'prompt="${1:-}"\n' 'lower=$(printf "%s" "$prompt" | tr "[:upper:]" "[:lower:]")\n' 'case "$lower" in\n' " *username*)\n" ' printf "%s\\n" "${FORGEJO_USERNAME:-x-token-auth}"\n' " ;;\n" " *)\n" ' printf "%s\\n" "${FORGEJO_PAT:-${GITEA_TOKEN:-}}"\n' " ;;\n" "esac\n" ) os.chmod(path, 0o700) _ASKPASS_PATH = Path(path) return _ASKPASS_PATH _ASKPASS_PATH: Path | None = None def _git_env_for_push() -> dict[str, str]: """Build a clean env for a git subprocess that pushes as HAL9000. Matches the ``_pr_clone_creds._GIT_ENV_PASSTHROUGH`` allowlist — only proxy / SSL / locale / TMPDIR vars carry through; everything else is dropped to keep unrelated secrets out of any git hook the push triggers. """ askpass = _ensure_askpass_for_hal9000() env: dict[str, str] = { "PATH": os.environ.get("PATH", "/usr/bin:/bin"), "HOME": os.environ.get("HOME", "/tmp"), "GIT_ASKPASS": str(askpass), "GIT_TERMINAL_PROMPT": "0", "FORGEJO_PAT": os.environ.get("FORGEJO_PAT", ""), "FORGEJO_USERNAME": os.environ.get("FORGEJO_USERNAME", "x-token-auth"), "GITEA_TOKEN": os.environ.get("GITEA_TOKEN", ""), } for name in ( "SSL_CERT_FILE", "SSL_CERT_DIR", "GIT_SSL_CAINFO", "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "http_proxy", "https_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "SSH_AUTH_SOCK", "SSH_AGENT_PID", ): v = os.environ.get(name) if v is not None: env[name] = v return env def _run_git( args: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, timeout: int = _DEFAULT_GIT_TIMEOUT_S, ) -> tuple[int, str, str]: """Run a git subprocess and return ``(returncode, stdout, stderr)``. Catches timeouts and FileNotFoundError; returns ``(-1, "", error_message)`` in those cases so callers can branch on ``returncode`` uniformly. """ cmd = ["git", *args] try: result = subprocess.run( cmd, cwd=str(cwd) if cwd is not None else None, env=env, capture_output=True, text=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired: return -1, "", f"git timed out after {timeout}s: {' '.join(cmd)}" except FileNotFoundError: return -1, "", "git binary not found on PATH" return result.returncode, result.stdout or "", result.stderr or "" # ``_err`` is the shared :func:`_mcp_common.error_envelope` imported above. # ─── Tools ───────────────────────────────────────────────────────── @server.tool() def isolate( pr: int, head_sha: str, head_ref: str | None = None, kind: str = "implementer", ) -> dict[str, Any]: """Pre-clone a PR's HEAD SHA into a fresh worktree. Wraps ``_pr_clone.prepare_pr_worktree`` which uses a bare mirror at ``/tmp/.cleveragents-mirror.git`` plus ``git worktree add`` for speed. Requires ``IMPLEMENTER_DISPATCHER_PRECLONE=1`` (or the review-side equivalent) — same gating as the dispatcher's own pre-clone path. Parameters: pr: PR number. head_sha: the SHA to materialise (caller is responsible for fetching this from Forgejo first — typically via the forgejo MCP's ``fetch_pr`` and reading ``head.sha``). head_ref: optional branch ref (saves a ``git for-each-ref`` call inside the helper). Passes through verbatim. kind: ``"implementer"`` or ``"review"``. Selects the worktree base and gating flag. Returns ``{worktree, branch, kind, run_tag}`` on success or ``{error: str}`` if the pre-clone is gated off / mirror fetch fails / SHA invalid. On gate-off the caller should fall back to a plain `clone` operation (not yet implemented as a tool). """ cfg = ForgejoCfg() err = require_token(cfg, "default") if err: return _err(err, pr=pr) try: handle = _pr_clone.prepare_pr_worktree( cfg, int(pr), head_sha, head_ref=head_ref or "", kind=kind, ) except Exception as exc: return _err(f"prepare_pr_worktree raised: {exc!r}", pr=pr) if handle is None: return _err( "pre-clone returned None (gated off, mirror fetch failed, or " "SHA invalid — check dispatcher logs)", pr=pr, ) return { "worktree": str(handle.path), "branch": handle.head_ref or "", "kind": handle.kind, "run_tag": handle.path.name.rsplit("-", 1)[-1], } @server.tool() def status(worktree: str) -> dict[str, Any]: """Compact working-tree status: branch, HEAD SHA, file lists. Returns: ``{worktree, branch, head_sha, staged, unstaged, untracked}`` where ``staged`` / ``unstaged`` / ``untracked`` are lists of file paths relative to the worktree root. Easier for the agent than parsing ``git status --porcelain`` itself. """ wt, err = _validate_worktree(worktree) if err: return _err(err) branch_rc, branch_out, _ = _run_git( ["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt ) sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt) porcelain_rc, porcelain_out, porcelain_err = _run_git( ["status", "--porcelain=v1", "-z"], cwd=wt ) if porcelain_rc != 0: return _err(f"git status failed: {porcelain_err.strip()}", worktree=str(wt)) staged: list[str] = [] unstaged: list[str] = [] untracked: list[str] = [] # -z: NUL-separated entries; "XY path" where X/Y are status codes for entry in porcelain_out.split("\x00"): if not entry or len(entry) < 3: continue x, y, path = entry[0], entry[1], entry[3:] if x == "?" and y == "?": untracked.append(path) continue if x != " ": staged.append(path) if y != " ": unstaged.append(path) return { "worktree": str(wt), "branch": branch_out.strip() if branch_rc == 0 else "", "head_sha": sha_out.strip() if sha_rc == 0 else "", "staged": staged, "unstaged": unstaged, "untracked": untracked, } @server.tool() def stage(worktree: str, paths: list[str]) -> dict[str, Any]: """``git add`` the given paths (relative to worktree). Returns ``{staged: [...], skipped: [{path, reason}]}``. Paths are passed individually so a single bad path doesn't fail the whole batch — each one's outcome is reported. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not paths: return _err("paths must be a non-empty list", worktree=str(wt)) staged: list[str] = [] skipped: list[dict[str, str]] = [] for p in paths: if not p or not isinstance(p, str): skipped.append({"path": str(p), "reason": "empty or non-string"}) continue # Reject absolute paths or path traversal that could escape # the worktree. ``git add`` itself would reject most of these # but a clear error here is friendlier than a cryptic git one. if p.startswith("/") or ".." in p.split("/"): skipped.append({"path": p, "reason": "absolute or contains '..'"}) continue rc, _, err_out = _run_git(["add", "--", p], cwd=wt) if rc != 0: skipped.append({"path": p, "reason": err_out.strip()[:200]}) else: staged.append(p) return {"staged": staged, "skipped": skipped} @server.tool() def commit( worktree: str, message: str, author_name: str | None = None, author_email: str | None = None, ) -> dict[str, Any]: """``git commit -m`` the staged changes. Returns ``{sha}`` on success or ``{error, stderr}`` on failure. ``author_name`` / ``author_email`` default to ``GIT_USER_NAME`` / ``GIT_USER_EMAIL`` from env which ``tools/launch_fork.sh`` sets to ``CleverThis`` / ``hal9000@cleverthis.com``. A commit with nothing staged returns ``{error: "nothing to commit"}`` rather than the git-default error message. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not message or not message.strip(): return _err("commit message must be non-empty", worktree=str(wt)) name = author_name or os.environ.get("GIT_USER_NAME") or "CleverThis" email = ( author_email or os.environ.get("GIT_USER_EMAIL") or "hal9000@cleverthis.com" ) env = dict(os.environ) env["GIT_AUTHOR_NAME"] = name env["GIT_AUTHOR_EMAIL"] = email env["GIT_COMMITTER_NAME"] = name env["GIT_COMMITTER_EMAIL"] = email rc, _, err_out = _run_git( ["commit", "-m", message], cwd=wt, env=env ) if rc != 0: lowered = err_out.lower() if "nothing to commit" in lowered or "no changes added" in lowered: return _err("nothing to commit", worktree=str(wt)) return _err(f"git commit failed", stderr=err_out.strip(), worktree=str(wt)) sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt) return {"sha": sha_out.strip() if sha_rc == 0 else ""} @server.tool() def push( worktree: str, remote: str = "origin", force_with_lease: bool = False, ) -> dict[str, Any]: """``git push`` the current branch. Authenticates as HAL9000 via the MCP's own askpass shim (does NOT use the reviewer PAT). Returns ``{remote_sha, branch}`` on success or ``{error, stderr}`` on failure. ``force_with_lease=True`` uses ``--force-with-lease`` which is safe against concurrent pushes (refuses to overwrite if the remote moved since the worker last fetched). Plain ``--force`` is intentionally NOT supported — it's the kind of thing whose blast radius justifies a human in the loop. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not os.environ.get("FORGEJO_PAT") and not os.environ.get("GITEA_TOKEN"): return _err( "FORGEJO_PAT / GITEA_TOKEN not set in MCP environment — push " "would prompt for credentials (which terminal_prompt=0 refuses)." ) branch_rc, branch_out, _ = _run_git( ["rev-parse", "--abbrev-ref", "HEAD"], cwd=wt ) if branch_rc != 0 or not branch_out.strip(): return _err("could not resolve current branch", worktree=str(wt)) branch = branch_out.strip() if branch == "HEAD": return _err( "worktree is in detached HEAD state — checkout a branch first", worktree=str(wt), ) env = _git_env_for_push() # Refresh the remote-tracking ref BEFORE pushing. Without this, # ``--force-with-lease`` (no value form) uses whatever # ``refs/remotes/{remote}/{branch}`` was at the start of the # cycle, which is usually stale by the time the worker reaches # the push step — leading to spurious # ``! [rejected] (stale info)`` failures even though there is no # actual concurrent push to race against. The fetch + explicit # ``--force-with-lease=:`` form pin the lease to the # tip we just observed, eliminating the false-rejection class. # Live-confirmed on 2026-05-16 (PR #30): five bash-path pushes # all failed with "stale remote state info" while the worker # made no other progress; this MCP path is engineered to avoid # that mode. # Explicit refspec form (``+:refs/remotes/origin/``) # is critical here: the bare ``git fetch origin `` shape # would update the local ```` if it exists, and git # REFUSES that when the local ```` is currently # checked out ("fatal: refusing to fetch into branch ... checked # out at ..."). The explicit-refspec form only touches the # remote-tracking ref, never the local branch, so it works # regardless of checkout state. The leading ``+`` is the # force-update marker — without it, a force-push on the remote # since our last sync would fail the fetch with non-fast-forward. # Live-confirmed root cause on 2026-05-16 (run-16 PR #29): the # bare-shape fetch failed silently, MCP fell through to bare # ``--force-with-lease`` with a stale tracking ref, push # rejected stale-info. Worker burned ~10 min troubleshooting. fetch_rc, _, fetch_err = _run_git( ["fetch", remote, f"+{branch}:refs/remotes/{remote}/{branch}"], cwd=wt, env=env, timeout=60, ) if fetch_rc != 0: # Non-fatal: pre-fetch failed (network / auth blip). Push # with the bare ``--force-with-lease`` form as a fallback; # the lease is stale but the agent at least sees a real # rejection if there's a genuine race. lease_sha = "" else: rt_rc, rt_out, _ = _run_git( ["rev-parse", f"{remote}/{branch}"], cwd=wt, env=env, timeout=15 ) lease_sha = rt_out.strip() if rt_rc == 0 else "" cmd = ["push"] if force_with_lease: if lease_sha: cmd.append( f"--force-with-lease=refs/heads/{branch}:{lease_sha}" ) else: cmd.append("--force-with-lease") cmd += [remote, branch] rc, _, err_out = _run_git(cmd, cwd=wt, env=env) if rc != 0: # One-shot retry for the specific "stale info" class — # another fetch + a fresh lease often clears it (the remote # may have advanced between our pre-fetch and the push). lower = (err_out or "").lower() if force_with_lease and "stale info" in lower: _run_git( ["fetch", remote, branch], cwd=wt, env=env, timeout=60 ) rt_rc, rt_out, _ = _run_git( ["rev-parse", f"{remote}/{branch}"], cwd=wt, env=env, timeout=15, ) retry_lease = rt_out.strip() if rt_rc == 0 else "" retry_cmd = ["push"] if retry_lease: retry_cmd.append( f"--force-with-lease=refs/heads/{branch}:{retry_lease}" ) else: retry_cmd.append("--force-with-lease") retry_cmd += [remote, branch] rc, _, err_out = _run_git(retry_cmd, cwd=wt, env=env) if rc != 0: return _err( "git push failed", stderr=(err_out or "").strip()[:1000], worktree=str(wt), branch=branch, ) # Read the remote-tracking ref after the push to confirm what landed. rt_rc, rt_out, _ = _run_git( ["rev-parse", f"{remote}/{branch}"], cwd=wt, env=env ) return { "remote_sha": rt_out.strip() if rt_rc == 0 else "", "branch": branch, "remote": remote, } @server.tool() def fetch( worktree: str, remote: str = "origin", branch: str | None = None, ) -> dict[str, Any]: """``git fetch`` from ``remote``. Authenticates as HAL9000. Parameters: remote: defaults to ``origin``. branch: optional. When provided, fetches ONLY this branch using the explicit refspec ``+:refs/remotes//`` so the operation works regardless of whether the local branch by that name is currently checked out. (The bare ``git fetch origin `` shape would refuse with "fatal: refusing to fetch into branch ... checked out at ..." in the checked-out case — same bug that bit the ``push`` MCP's internal pre-fetch on 2026-05-16, run-16 PR #29.) When ``branch`` is omitted the fetch updates every remote-tracking ref the default refspec covers. Returns ``{ok: bool}`` on success or ``{error, stderr}`` on failure. Output (which is usually noise like ``From https://...``) is not returned; if you need it, run ``status`` afterward. """ wt, err = _validate_worktree(worktree) if err: return _err(err) env = _git_env_for_push() if branch: cmd = [ "fetch", remote, f"+{branch}:refs/remotes/{remote}/{branch}", ] else: cmd = ["fetch", remote] rc, _, err_out = _run_git(cmd, cwd=wt, env=env) if rc != 0: return _err( "git fetch failed", stderr=err_out.strip(), worktree=str(wt), ) return {"ok": True} @server.tool() def checkout( worktree: str, branch: str, create: bool = False, force: bool = False, ) -> dict[str, Any]: """``git checkout`` — switch to (or create) a branch. Parameters: branch: target branch name. create: when True, uses ``-B`` to create-or-reset the branch at the current HEAD. This is the common pattern for converting a detached HEAD (left by the dispatcher's pre-clone ``worktree add --detach`` flow) back into a named branch before pushing. Without this, ``git push`` rejects with "src refspec HEAD does not match any" / "you are not currently on a branch." force: when True, uses ``-f`` to discard local changes that would otherwise block the checkout. Use sparingly — silent data loss risk if the worker had uncommitted work. Returns ``{branch, head_sha}`` on success or ``{error, stderr}`` on failure. The 2026-05-16 (run-16 PR #29) trace showed task-implementor repeatedly dropping to bash for ``git checkout -B `` because no MCP tool existed for it. This tool closes that gap so the MCP path covers the full git workflow. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not branch or not branch.strip(): return _err("branch must be non-empty", worktree=str(wt)) cmd = ["checkout"] if create: cmd.append("-B") elif force: cmd.append("-f") cmd.append(str(branch)) rc, _, err_out = _run_git(cmd, cwd=wt) if rc != 0: return _err( "git checkout failed", stderr=err_out.strip(), worktree=str(wt), branch=branch, ) # Report the post-checkout state so the caller can verify they # ended up on the expected branch + SHA. sha_rc, sha_out, _ = _run_git(["rev-parse", "HEAD"], cwd=wt) return { "branch": branch, "head_sha": sha_out.strip() if sha_rc == 0 else "", } @server.tool() def rebase(worktree: str, onto: str) -> dict[str, Any]: """``git rebase`` the current branch onto ``onto`` (e.g. ``origin/master``). Returns ``{success: True}`` on a clean rebase or ``{success: False, conflicts: [path, …]}`` when the rebase stops on a conflict — the worktree is left in the rebase-in-progress state so the agent can resolve conflicts and then either run a follow-up tool (not yet exposed) or fall back to bash to ``git rebase --continue`` / ``--abort``. On non-conflict errors returns ``{error, stderr}``. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not onto or not onto.strip(): return _err("`onto` ref must be non-empty", worktree=str(wt)) rc, _, err_out = _run_git(["rebase", onto], cwd=wt) if rc == 0: return {"success": True} # Conflict detection: when a rebase stops on conflicts git exits # non-zero and ``git status --porcelain`` lists the files with # 'U' codes (unmerged). Pull those out so the agent has a list. porcelain_rc, porcelain_out, _ = _run_git( ["status", "--porcelain=v1"], cwd=wt ) if porcelain_rc == 0: conflicts = [ line[3:].strip() for line in porcelain_out.splitlines() if line.startswith(("U", "AA", "DD")) or " U " in line[:3] ] if conflicts: return { "success": False, "conflicts": conflicts, "stderr": err_out.strip()[:500], } return _err(f"git rebase failed", stderr=err_out.strip(), worktree=str(wt)) @server.tool() def cleanup(worktree: str) -> dict[str, Any]: """Remove a worktree. Runs ``git worktree remove --force`` against the mirror so git's metadata stays consistent, then deletes the directory if any files remain. Returns ``{removed: True}`` on success or ``{error}`` on failure. Safe to call on an already-removed worktree. """ wt, err = _validate_worktree(worktree) if err: # If the directory doesn't exist that's actually success for # cleanup — the post-condition (path is gone) already holds. if "does not exist" in (err or ""): return {"removed": True, "note": "already absent"} return _err(err) # Best-effort: ask the mirror to deregister the worktree. The # mirror path follows the dispatcher's convention. mirror = Path("/tmp/.cleveragents-mirror.git") if mirror.is_dir(): _run_git( ["--git-dir", str(mirror), "worktree", "remove", "--force", str(wt)], ) # Whatever the worktree-remove result, scrub any remaining files # so the agent sees a clean post-condition. try: shutil.rmtree(wt, ignore_errors=True) except OSError as exc: return _err(f"rmtree failed: {exc}", worktree=str(wt)) return {"removed": True} # ─── Read-only inspection tools (2026-05-16, Step 4) ─────────────── # Added so the git MCP covers the full surface that the soon-to-be- # retired ``git-*-util`` subagent fleet supported. Without these, # callers migrating off ``git-commit-util`` / ``git-rebase-util`` / # etc. would still need bash perms for ``git log`` / ``git diff`` / # ``git show`` / ``git rev-parse`` / ``git merge-base`` to verify # the state of their work — keeping the bash dependency the MCP # migration was supposed to eliminate. All five are read-only with # no side effects beyond stdout/stderr. @server.tool() def log( worktree: str, range: str | None = None, max_count: int = 20, oneline: bool = True, paths: list[str] | None = None, ) -> dict[str, Any]: """``git log`` — list commits. Defaults to oneline format capped at 20 commits. Parameters: range: e.g. ``"master..HEAD"`` (commits in HEAD not in master), ``"HEAD~5..HEAD"``, or omit for full history. max_count: ``-n`` flag; capped at 200 to bound output. oneline: ``--oneline`` (default). Set False for the full commit-message format. paths: limit log to commits touching these paths (e.g. ``["src/foo.py", "tests/test_foo.py"]``). Returns ``{output: str}`` on success or ``{error, stderr}`` on failure. """ wt, err = _validate_worktree(worktree) if err: return _err(err) cmd = ["log"] if oneline: cmd.append("--oneline") n = max(1, min(int(max_count), 200)) cmd += ["-n", str(n)] if range: cmd.append(str(range)) if paths: cmd.append("--") cmd += [str(p) for p in paths if p] rc, out, err_out = _run_git(cmd, cwd=wt) if rc != 0: return _err("git log failed", stderr=err_out.strip(), worktree=str(wt)) return {"output": out} @server.tool() def diff( worktree: str, ref1: str | None = None, ref2: str | None = None, paths: list[str] | None = None, name_only: bool = False, stat: bool = False, ) -> dict[str, Any]: """``git diff`` between refs or against the working tree. Parameters: ref1 / ref2: omit both → diff working tree vs HEAD. Specify ref1 only → diff working tree vs ref1. Specify both → ``git diff ref1 ref2`` (use ``"master...HEAD"`` syntax in ref1 for triple-dot semantics). paths: limit diff to these paths. name_only: ``--name-only`` (list of changed files, no hunks). stat: ``--stat`` (insertions/deletions summary, no hunks). Returns ``{output: str}`` on success or ``{error, stderr}`` on failure. ``name_only`` and ``stat`` are mutually exclusive; if both are True ``stat`` wins. """ wt, err = _validate_worktree(worktree) if err: return _err(err) cmd = ["diff"] if stat: cmd.append("--stat") elif name_only: cmd.append("--name-only") if ref1: cmd.append(str(ref1)) if ref2: cmd.append(str(ref2)) if paths: cmd.append("--") cmd += [str(p) for p in paths if p] rc, out, err_out = _run_git(cmd, cwd=wt) if rc != 0: return _err("git diff failed", stderr=err_out.strip(), worktree=str(wt)) return {"output": out} @server.tool() def show( worktree: str, ref: str, path: str | None = None, ) -> dict[str, Any]: """``git show`` a commit or a file's content at a specific ref. Without ``path``: shows the commit (message + diff) at ``ref``. With ``path``: shows the file content at ``ref:path`` (e.g. ``ref="HEAD"`` ``path="README.md"`` returns README at HEAD). Returns ``{output: str}`` on success or ``{error, stderr}`` on failure. Useful for reading the file as it existed in a previous commit without checking out. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not ref or not ref.strip(): return _err("ref must be non-empty", worktree=str(wt)) target = f"{ref}:{path}" if path else ref rc, out, err_out = _run_git(["show", target], cwd=wt) if rc != 0: return _err( "git show failed", stderr=err_out.strip(), worktree=str(wt) ) return {"output": out} @server.tool() def rev_parse( worktree: str, ref: str, abbrev_ref: bool = False, ) -> dict[str, Any]: """``git rev-parse`` — resolve a ref to a SHA (or to its abbreviated-ref / branch name). Parameters: ref: anything git can parse — ``"HEAD"``, ``"master"``, ``"origin/main"``, ``"HEAD~3"``, a tag, a partial SHA. abbrev_ref: ``--abbrev-ref`` — return the branch name a ref points to (e.g. ``rev_parse("HEAD", abbrev_ref=True)`` → ``"feature/x"``) instead of the SHA. ``git_status`` already returns the branch name; this flag exists for the rarer case of resolving an arbitrary ref's branch. Returns ``{sha: str}`` (or ``{branch: str}`` when abbrev_ref) on success, or ``{error, stderr}`` on failure. """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not ref or not ref.strip(): return _err("ref must be non-empty", worktree=str(wt)) cmd = ["rev-parse"] if abbrev_ref: cmd.append("--abbrev-ref") cmd.append(str(ref)) rc, out, err_out = _run_git(cmd, cwd=wt) if rc != 0: return _err( "git rev-parse failed", stderr=err_out.strip(), worktree=str(wt) ) value = out.strip() return {"branch": value} if abbrev_ref else {"sha": value} @server.tool() def merge_base( worktree: str, ref1: str, ref2: str, ) -> dict[str, Any]: """``git merge-base`` — find the common ancestor SHA of two refs. Used to compute the "PR fork point" (``merge_base("HEAD", "origin/master")``) for diff-base resolution and for verifying a rebase landed on the expected base. Returns ``{sha: str}`` on success or ``{error, stderr}`` on failure (the most common failure is unrelated histories — e.g. you compared two refs that share no common commit). """ wt, err = _validate_worktree(worktree) if err: return _err(err) if not ref1 or not ref2: return _err("both ref1 and ref2 must be non-empty", worktree=str(wt)) rc, out, err_out = _run_git( ["merge-base", str(ref1), str(ref2)], cwd=wt ) if rc != 0: return _err( "git merge-base failed", stderr=err_out.strip(), worktree=str(wt), ) return {"sha": out.strip()} main = make_main(server, "mcp_git_server") if __name__ == "__main__": sys.exit(main())