Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ python -m mcp_server_git

## Configuration

`--repository` accepts any path inside a Git working tree. On startup the server walks up to the enclosing `.git/`, the same way `git rev-parse --show-toplevel` does. So `--repository .` works when a shared config is run from any subdirectory of the repo.

Note that this also means `--repository /repo/subdir` resolves to `/repo` and allows tool calls anywhere under it. If the resolved path differs from the input, startup logs show the rewrite. If you want to restrict operations to a subtree, this flag alone won't do it; use a separate repo or an external sandbox.

Per-tool `repo_path` arguments are not resolved this way and must point at the repo root.

### Usage with Claude Desktop

Add this to your `claude_desktop_config.json`:
Expand Down
17 changes: 14 additions & 3 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@ def git_show(repo: git.Repo, revision: str) -> str:
output.append(d.diff)
return "".join(output)

def resolve_repo_root(path: Path) -> Path:
"""Return the enclosing repo root for ``path``, like ``git rev-parse --show-toplevel``."""
repo = git.Repo(path, search_parent_directories=True)
if repo.working_dir:
return Path(repo.working_dir)
return Path(path).resolve()


def validate_repo_path(repo_path: Path, allowed_repository: Path | None) -> None:
"""Validate that repo_path is within the allowed repository path."""
if allowed_repository is None:
Expand Down Expand Up @@ -295,11 +303,14 @@ async def serve(repository: Path | None) -> None:

if repository is not None:
try:
git.Repo(repository)
logger.info(f"Using repository at {repository}")
resolved = resolve_repo_root(repository)
except git.InvalidGitRepositoryError:
logger.error(f"{repository} is not a valid Git repository")
logger.error(f"{repository} is not inside a Git repository")
return
if resolved != repository:
logger.info(f"Resolved --repository {repository} to repo root {resolved}")
repository = resolved
logger.info(f"Using repository at {repository}")

server = Server("mcp-git")

Expand Down
75 changes: 75 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio
import logging
import pytest
from pathlib import Path
import git
Expand All @@ -15,6 +17,8 @@
git_log,
git_create_branch,
git_show,
resolve_repo_root,
serve,
validate_repo_path,
)
import shutil
Expand Down Expand Up @@ -252,6 +256,77 @@ def test_git_show_initial_commit(test_repository):
assert "test.txt" in result


# Tests for resolve_repo_root (#3029)

def test_resolve_repo_root_at_root(tmp_path: Path):
repo_path = tmp_path / "repo"
git.Repo.init(repo_path)
assert resolve_repo_root(repo_path).resolve() == repo_path.resolve()


def test_resolve_repo_root_from_subdirectory(tmp_path: Path):
repo_path = tmp_path / "repo"
git.Repo.init(repo_path)
subdir = repo_path / "nested" / "deeper"
subdir.mkdir(parents=True)
assert resolve_repo_root(subdir).resolve() == repo_path.resolve()


def test_resolve_repo_root_outside_any_repository(tmp_path: Path):
non_repo = tmp_path / "not_a_repo"
non_repo.mkdir()
with pytest.raises(git.InvalidGitRepositoryError):
resolve_repo_root(non_repo)


def test_resolve_repo_root_dot_from_subdirectory(tmp_path: Path, monkeypatch):
# `--repository .` from a subdir should walk up to the repo root.
repo_path = tmp_path / "repo"
git.Repo.init(repo_path)
subdir = repo_path / "nested"
subdir.mkdir()
monkeypatch.chdir(subdir)
assert resolve_repo_root(Path(".")).resolve() == repo_path.resolve()


class _StopServe(Exception):
pass


def _fake_stdio_server():
raise _StopServe()


def test_serve_resolves_subdirectory_at_startup(tmp_path: Path, monkeypatch, caplog):
repo_path = tmp_path / "repo"
git.Repo.init(repo_path)
subdir = repo_path / "nested"
subdir.mkdir()

monkeypatch.setattr("mcp_server_git.server.stdio_server", _fake_stdio_server)
caplog.set_level(logging.INFO, logger="mcp_server_git.server")

with pytest.raises(_StopServe):
asyncio.run(serve(subdir))

messages = [r.message for r in caplog.records]
assert any("Resolved --repository" in m and str(subdir) in m for m in messages)
assert any(f"Using repository at {repo_path}" in m for m in messages)


def test_serve_rejects_path_outside_any_repository(tmp_path: Path, monkeypatch, caplog):
non_repo = tmp_path / "not_a_repo"
non_repo.mkdir()

monkeypatch.setattr("mcp_server_git.server.stdio_server", _fake_stdio_server)
caplog.set_level(logging.ERROR, logger="mcp_server_git.server")

asyncio.run(serve(non_repo))

messages = [r.message for r in caplog.records]
assert any("is not inside a Git repository" in m for m in messages)


# Tests for validate_repo_path (repository scoping security fix)

def test_validate_repo_path_no_restriction():
Expand Down
Loading