From 2de277a47a29ceeb5b5a7aeac9bb7c982d8efb0d Mon Sep 17 00:00:00 2001 From: Fazeel Usmani Date: Tue, 21 Apr 2026 18:58:42 +0530 Subject: [PATCH] fix(git): auto-detect repo root from --repository Walk parent directories when --repository points inside a working tree, the same way `git rev-parse --show-toplevel` does. Makes `--repository .` work from any subdirectory of the repo so a checked-in config works for every developer regardless of where they launch from. If the resolved path differs from the input, the rewrite is logged at startup. Per-tool repo_path arguments are unchanged. Closes #3029 --- src/git/README.md | 6 +++ src/git/src/mcp_server_git/server.py | 17 +++++-- src/git/tests/test_server.py | 75 ++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index c9ec3140be..4fbef95bff 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -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`: diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 5ce953e545..29d1d8a31c 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -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: @@ -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") diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index a5492adc85..44ecd03a02 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -1,3 +1,5 @@ +import asyncio +import logging import pytest from pathlib import Path import git @@ -15,6 +17,8 @@ git_log, git_create_branch, git_show, + resolve_repo_root, + serve, validate_repo_path, ) import shutil @@ -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():