← Back to Blog

Build a Git Repo MCP Server With the Python SDK

Your agent can read files. What it usually cannot do is answer "what landed on this branch this week, and what is still uncommitted" without running five shell commands and then guessing at the output format.

A small MCP server fixes that. Instead of a shell prompt, the model gets three typed calls: recent commits, changed files, and the README. With the current stable line of the official Python SDK (mcp 2.3.0), each one is a plain function with a docstring. The argument schema comes from the type hints, so there is no JSON Schema to keep in sync.

What you need

  • Python 3.11 or newer
  • uv, which the SDK docs use throughout (plain pip works too)
  • git and a repo to point at
  • An MCP host: Claude Code, Cursor, or VS Code 1.99 or newer with the GitHub Copilot extension in agent mode

Tools, resources, prompts: choose by who decides

A server exposes three kinds of thing, and the split is about control:

  • A tool is a function the model calls on its own.
  • A resource is data the host loads into context.
  • A prompt is a template the user invokes by name.

For repo inspection, commits and working-tree state are tools, because the model should decide when to look. The README is a resource, because the host can attach it up front and save a round trip. No prompt here; a prompt only earns its place when a human wants a slash command.

The server

Create server.py:

import subprocess
from pathlib import Path

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer(
    "Repo Lens",
    instructions="Inspect the repo before proposing changes. Call recent_commits first.",
)


class Commit(BaseModel):
    sha: str
    author: str
    subject: str
    date: str


def _git(repo: str, *args: str) -> str:
    path = Path(repo).expanduser().resolve()
    if not (path / ".git").exists():
        raise ToolError(f"{path} is not a git repository")
    result = subprocess.run(
        ["git", "-C", str(path), *args],
        capture_output=True,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        raise ToolError(result.stderr.strip() or "git failed")
    return result.stdout


@mcp.tool()
def recent_commits(repo: str, limit: int = 5) -> list[Commit]:
    """List recent commits on the current branch of a local repo.

    Use this before proposing a diff so you know what changed lately.
    """
    out = _git(
        repo,
        "log",
        f"-n{limit}",
        "--pretty=format:%h\x1f%an\x1f%s\x1f%ad",
        "--date=short",
    )
    commits = []
    for line in out.splitlines():
        sha, author, subject, date = line.split("\x1f")
        commits.append(Commit(sha=sha, author=author, subject=subject, date=date))
    return commits


@mcp.tool()
def changed_files(repo: str) -> list[str]:
    """List files with uncommitted changes, including untracked ones."""
    out = _git(repo, "status", "--porcelain")
    return [line[3:] for line in out.splitlines() if line.strip()]


@mcp.resource("repo://{repo}/readme")
def readme(repo: str) -> str:
    """The repository README, for project context."""
    path = Path(repo).expanduser().resolve()
    for name in ("README.md", "readme.md", "README"):
        candidate = path / name
        if candidate.exists():
            return candidate.read_text(encoding="utf-8")
    return f"(no README at {path})"


if __name__ == "__main__":
    mcp.run()

Three details explain most of the behavior you will see in a client:

  • The decorator is the whole registration. Name, description, and schema are read off the function.
  • The docstring becomes the tool description that the model reads. Keep the first line short and concrete; an agent choosing between eight tools reads that line, not your source.
  • A default value turns the parameter optional. limit: int = 5 arrives in the schema as an optional integer with a default of 5.

mcp.run() with no arguments serves over stdio, which is what local hosts expect. Guard it with if __name__ == "__main__", since the host imports the module to read the server object.

Run it in the Inspector

uv run mcp dev server.py

The Inspector prints a local URL. You get one tab per primitive, and the argument form is generated from your type hints. A wrong annotation shows up as a wrong form before any model is involved, which is the cheapest debugging you will get all day.

Test it without a host

No subprocess, no port. Hand the server object to Client:

import anyio

from mcp import Client

from server import mcp


async def main() -> None:
    async with Client(mcp) as client:
        tools = await client.list_tools()
        print([tool.name for tool in tools.tools])

        result = await client.call_tool("recent_commits", {"repo": "/home/me/code/api", "limit": 3})
        print(result.is_error)
        print(result.structured_content)


if __name__ == "__main__":
    anyio.run(main)

This connects in-process and exposes the same methods as a real session: list_tools, call_tool, read_resource. Point repo at a real path when you run it, because a raising tool does not raise here. It comes back as a normal result with is_error=True and structured_content set to None.

That last point is the one that bites people in production. Any client code that reads structured_content without checking is_error first will treat a failed call as an empty success.

Connect a host

Claude Code takes one command, where everything after -- is the launch command:

claude mcp add repo-lens -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py

Run /mcp inside a session to confirm it connected and listed its tools.

Cursor reads .cursor/mcp.json in the project root:

{
  "mcpServers": {
    "repo-lens": {
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}

VS Code reads .vscode/mcp.json and uses a different wrapper key:

{
  "servers": {
    "repo-lens": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
    }
  }
}

After editing any of these, restart the host. Claude Desktop needs a full quit, not a closed window, because it reads the config once at launch.

Four things that break

  1. Something writes to stdout. On stdio, stdout is the protocol. A stray print() at import time, or a wrapper script that echoes a banner, hands the host a corrupt message and the connection drops. Use logging, whose default handler writes to stderr.
  2. The path is relative. The host launches your server from its own working directory, not the one you typed the config in. Every path in the config has to be absolute, including the path to uv if the host cannot find it.
  3. A plain exception instead of ToolError. A ToolError message lands in the result content, where the model can read it and retry. Any other exception collapses into Error executing tool with no detail, and the agent has nothing to correct.
  4. The server is fine but the tool list is stale. Add a tool, forget the restart, and the host is still showing the old list from its last launch.

When this beats a shell, and when it does not

If your agent already has shell access and permission to run git, this server is a guardrail, not a new capability. What you buy is a fixed set of commands with typed arguments, so the model cannot invent a flag or land in an interactive pager. What you pay is one more process and one more config file.

It earns its keep in three cases. The repo lives on another machine, so you serve the same object over Streamable HTTP and the agent gets a URL instead of filesystem access. You want to restrict what can be run, for example read-only tools with no commit or push. Or you are on a host without a shell at all, where MCP is the only channel.

The alternative for remote repos is GitHub's own MCP server, which covers issues, pull requests, and code search across a whole org. Building your own makes sense when the thing you need to expose is not on GitHub, like a private deploy dashboard or an internal service catalog.

Next steps

Keep the first version read-only on purpose. A tool that commits is a footgun until you have a confirmation path, and the model will call it confidently on a dirty tree.

Two additions that are worth the effort: a search_code tool wrapping git grep -n, which gives the agent exact line numbers instead of a guess, and a root allowlist. Right now repo accepts any path on disk. That is tolerable for a local stdio server you started yourself and unacceptable the moment you expose it over HTTP, because then the caller chooses the path.

The whole file is around 60 lines. That is the point: instead of teaching an agent git through prose in a system prompt, you hand it three functions with schemas it cannot get wrong.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation