diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 5ce953e545..4ebc643c19 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -1,3 +1,4 @@ +import json import logging from pathlib import Path from typing import Sequence, Optional @@ -42,7 +43,10 @@ class GitCommit(BaseModel): class GitAdd(BaseModel): repo_path: str - files: list[str] + files: list[str] | str = Field( + ..., + description="The files to stage. Normally a list of paths. A JSON-encoded array string such as '[\"a.py\", \"b.py\"]' or a single path string are also accepted for leniency.", + ) class GitReset(BaseModel): repo_path: str @@ -129,7 +133,23 @@ def git_commit(repo: git.Repo, message: str) -> str: commit = repo.index.commit(message) return f"Changes committed successfully with hash {commit.hexsha}" -def git_add(repo: git.Repo, files: list[str]) -> str: +def normalize_file_list(files: list[str] | str) -> list[str]: + # Some clients send the files argument as a JSON-encoded array string, + # e.g. '["a.py", "b.py"]', instead of a real array. Accept that form, and + # also accept a single bare path string, so a stray string does not fail + # the call outright. + if isinstance(files, str): + try: + parsed = json.loads(files) + except json.JSONDecodeError: + return [files] + if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed): + return parsed + return [files] + return files + +def git_add(repo: git.Repo, files: list[str] | str) -> str: + files = normalize_file_list(files) if files == ["."]: repo.git.add(".") else: diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index a5492adc85..a55997fbda 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -6,6 +6,7 @@ git_checkout, git_branch, git_add, + normalize_file_list, git_status, git_diff_unstaged, git_diff_staged, @@ -109,6 +110,45 @@ def test_git_add_specific_files(test_repository): assert "file2.txt" not in staged_files assert result == "Files staged successfully" +def test_git_add_json_encoded_string(test_repository): + file1 = Path(test_repository.working_dir) / "file1.txt" + file2 = Path(test_repository.working_dir) / "file2.txt" + file1.write_text("file 1 content") + file2.write_text("file 2 content") + + # Some clients send the files argument as a JSON-encoded array string. + result = git_add(test_repository, '["file1.txt", "file2.txt"]') + + staged_files = [item.a_path for item in test_repository.index.diff("HEAD")] + assert "file1.txt" in staged_files + assert "file2.txt" in staged_files + assert result == "Files staged successfully" + +def test_git_add_single_path_string(test_repository): + file1 = Path(test_repository.working_dir) / "file1.txt" + file2 = Path(test_repository.working_dir) / "file2.txt" + file1.write_text("file 1 content") + file2.write_text("file 2 content") + + # A single bare path string is treated as one file, not split apart. + result = git_add(test_repository, "file1.txt") + + staged_files = [item.a_path for item in test_repository.index.diff("HEAD")] + assert "file1.txt" in staged_files + assert "file2.txt" not in staged_files + assert result == "Files staged successfully" + +def test_normalize_file_list(): + # Real lists pass through unchanged. + assert normalize_file_list(["a.py", "b.py"]) == ["a.py", "b.py"] + # JSON-encoded array strings are parsed into a list. + assert normalize_file_list('["a.py", "b.py"]') == ["a.py", "b.py"] + # A bare path that is not valid JSON is kept as a single entry. + assert normalize_file_list("a.py") == ["a.py"] + # A JSON value that is not a list of strings is treated as a single path. + assert normalize_file_list('{"a": 1}') == ['{"a": 1}'] + assert normalize_file_list("[1, 2]") == ["[1, 2]"] + def test_git_status(test_repository): result = git_status(test_repository)