From 4180d90e18b0490cbe7a8de89965a7f48db69124 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 1 Jun 2026 17:50:04 -0700 Subject: [PATCH] fix(git): accept JSON-encoded string for git_add files argument Some clients send the files argument to git_add as a JSON-encoded array string, for example '["a.py", "b.py"]', rather than a real array. The published input schema declared files as an array only, so the SDK rejected the call at the validation layer with "is not of type array". Even past that layer, git_add passed the raw string through to git, which split it into individual characters. Widen the schema to accept a list of strings or a string, then normalize a string into a list inside git_add: parse a JSON array of strings when present, otherwise treat the value as a single path. Real lists are unchanged. Fixes #4242 --- src/git/src/mcp_server_git/server.py | 24 +++++++++++++++-- src/git/tests/test_server.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) 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)