Skip to content

mcp-server-git: git_status and the git_diff tools print non-ASCII paths as octal escapes, so git_add cannot take them back #5129

Open

Description

@chrikrah

Which server?

git (mcp-server-git)

Server version

2026.10.10, from v2/main at 2f8dc42

How did you run the server?

From a clone of this repository

Transport

stdio

Protocol era

Not sure. The repro calls the tool functions in-process, so it does not depend on the era.

MCP client and version

n/a, the tool functions were called directly from Python

Operating system and runtime

Linux, Python 3.10.21, git 2.43.0, GitPython 3.2.0

Server configuration

None beyond uv sync --frozen --dev in src/git.

Steps to reproduce

  1. In a fresh repository, create an untracked file named 日本.txt.
  2. Call git_status, strip the surrounding double quotes from the name it prints, and pass that name to git_add.
  3. Save the script below as repro.py in src/git and run it.
repro.py
import pathlib, subprocess, tempfile
import git
import mcp_server_git.server as s
d = pathlib.Path(tempfile.mkdtemp())
subprocess.run(["git", "init", "-q", str(d)], check=True)
r = git.Repo(d)
(d / "日本.txt").write_text("a\n")
print("--- git_status")
st = s.git_status(r)
print("\n".join(l for l in st.splitlines() if "Untracked" in l or ".txt" in l))
name = [l.strip() for l in st.splitlines() if ".txt" in l][0].strip('"')
r.git.add("日本.txt")
print("--- git_diff_staged (headers)")
print("\n".join(l for l in s.git_diff_staged(r).splitlines() if l.startswith("diff --git")))
r.git.rm("--cached", "-q", "日本.txt")
print("--- git_add with the name git_status printed")
try:
    s.git_add(r, [name])
except Exception as e:
    print(type(e).__name__ + ":", str(e).split("stderr: ")[-1].strip().strip("'"))
print("--- the same status with -c core.quotepath=false")
print("\n".join(l.strip() for l in r.git(c="core.quotepath=false").status().splitlines() if ".txt" in l))
$ uv run python repro.py      # src/git at 2f8dc42
--- git_status
Untracked files:
	"\346\227\245\346\234\254.txt"
--- git_diff_staged (headers)
diff --git "a/\346\227\245\346\234\254.txt" "b/\346\227\245\346\234\254.txt"
--- git_add with the name git_status printed
GitCommandError: fatal: pathspec '\346\227\245\346\234\254.txt' did not match any files
--- the same status with -c core.quotepath=false
日本.txt

Expected behavior

git_status and the three diff tools print 日本.txt, a name a client can pass back to git_add.

Actual behavior

src/git/src/mcp_server_git/server.py:155 calls repo.git.status() with git's default core.quotepath=true, so every non-ASCII path comes back as quoted octal escapes. git_diff_unstaged, git_diff_staged and git_diff do the same.

Already prototyped a fix?

#4879 by @tsurutanmen has the change and four tests for it, written against main. It runs the four commands with -c core.quotepath=false.

Before you submit

  • I searched existing issues and this is not a duplicate.
  • This is not a security vulnerability report (those go through the private advisory form).

@cliffhall could this go on the board for the git server?

Activity

  1. SU-AN-coder commented on Oct 18, 2026

    @SU-AN-coder

    Independent reproduction on v2/main, and a check of the prototyped fix

    I reproduced this on the current development branch and then ran the approach prototyped in #4879 against it, to see whether it delivers the behavior in the Expected behavior section ("a name a client can pass back to git_add"). It does, for the reported case. Two details are worth folding into the change, and one limit is worth not overclaiming.

    Revision: v2/main @ 38569bd5 (2026-10-13), server version 2026.10.10
    Environment: git 2.43.0, GitPython 3.2.0, Python 3.10.20, Linux, in-process tool calls from src/git after uv sync --frozen --dev

    Reproduction

    Same shape as the report's repro.py: create 日本.txt, take the line git_status prints, strip the surrounding quotes the way a client would, and pass the result to git_add.

    $ cd src/git && uv run python repro.py
    git version 2.43.0 / GitPython 3.2.0
    
    git_status untracked line : ['\t"\\346\\227\\245\\346\\234\\254.txt"']
    name a client would reuse : '\\346\\227\\245\\346\\234\\254.txt'
    git_add(that name)        : FAILS: fatal: pathspec '\346\227\245\346\234\254.txt' did not match any files
    git_add(the real name)    : round-trip OK
    

    The four tools the report names, with one non-ASCII file that has a staged change:

    git_diff_unstaged hdr: ['diff --git "a/\\346\\227\\245\\346\\234\\254.txt" "b/\\346\\227\\245\\346\\234\\254.txt"',
                            '--- "a/\\346\\227\\245\\346\\234\\254.txt"', '+++ "b/\\346\\227\\245\\346\\234\\254.txt"']
    git_diff_staged   hdr: (identical)
    git_diff(HEAD)    hdr: (identical)
    git_status        line: ['\tmodified:   "\\346\\227\\245\\346\\234\\254.txt"']
    

    Root cause, and the whole surface

    call site code at 38569bd5
    server.py:155-156 def git_status(repo): return repo.git.status()
    server.py:159-162 return repo.git.diff(f"--unified={context_lines}")
    server.py:165-166 return repo.git.diff(f"--unified={context_lines}", "--cached")
    server.py:169-177 return repo.git.diff(f"--unified={context_lines}", target)

    All four hand git's stdout straight to the client while git's default core.quotepath=true is in effect, so every path byte ≥ 0x80 quote_c_styles to \ddd octal.

    Those four are the entire surface. git_show (server.py:375-421) is the control: at server.py:411-412 it builds its paths from GitPython's Diff objects (d.a_path, d.b_path), which are decoded, so it prints --- a/日本.txt with no flag needed. That is also the cheapest way to describe the bug — the one tool that parses instead of passing through is the one tool that was already correct.

    Verifying the -c core.quotepath=false approach (as prototyped in #4879)

    I ported that change and its four tests onto v2/main @ 38569bd5 — helper plus the four call sites, tests for git_status, git_diff_unstaged, git_diff_staged, git_diff with 日本語.txt and core.quotepath=true pinned in the fixture.

    Before/after on the report's repro:

                 before                              after
    git_status   '\t"\\346\\227\\245\\346\\234\\254.txt"'   '\t日本.txt'
    client name  '\\346\\227\\245\\346\\234\\254.txt'        '日本.txt'
    git_add      pathspec did not match any files       round-trip OK
    diff headers 'diff --git "a/\\346\\227..."  ...'      'diff --git a/日本.txt b/日本.txt'
    

    Test and gate results, all in that tree:

    • The four ported tests against unmodified v2/main: 4 failed (assert '日本語.txt' in result → the octal escape is what is in the result). With the change applied: 4 passed.
    • Ported tests plus the rest: 185 passed (uv run pytest -q, 20.3s).
    • uv run ruff check . → All checks passed!; uv run ruff format --check . → clean; uv run pyright → 0 errors, 0 warnings, 0 informations.
    • The setting stays out of the repository: .git/config sha256 is unchanged across a call. core.quotepath is not present in the config afterwards.
    • ASCII-only repositories are byte-identical: git_status, git_diff_unstaged and git_diff_staged output matches the same calls without the flag, character for character.

    Two things worth knowing when implementing it

    1. The GitPython spelling matters, and the obvious one is broken. core.quotepath must land before the subcommand, so the per-command kwarg form fails at runtime rather than silently:

    repo.git.status(c="core.quotepath=false")
      -> GitCommandError: exit code(129)
         cmdline: git status -c core.quotepath=false
         stderr: 'error: unknown switch `c''
    
    repo.git(c="core.quotepath=false").status()          -> '\t日本.txt'   (correct)
    repo.git.execute(["git","-c","core.quotepath=false","status"]) -> '\t日本.txt'   (correct)
    

    2. repo.git(c=...) mutates the shared instance, transiently. It returns repo.git itself (Git.__call__ stores _git_options and return self): while pending, repo.git._git_options == ['-c', 'core.quotepath=false'], and it is cleared when the next command executes. I checked this is not a leak for the server: run_tool builds a fresh git.Repo(repo_path) per call at server.py:670, so there is no second call on the object that could inherit it, and a later plain repo.git.status() on the same object escapes octal again (measured). So the helper is safe here — it is worth a comment in the code, not a redesign.

    One limit of the flag (not covered by the four tests)

    core.quotepath only governs the high-bit set. Names git C-quotes for other reasons stay quoted, and git_add still cannot take one of them back:

    name                                  status shows                     client reuses it          -> git_add
    '日本.txt'  (the report's case)        '日本.txt'                        '日本.txt'                round-trip OK
    'café.txt'                            'café.txt'                       'café.txt'                round-trip OK
    '🎉.txt'                              '🎉.txt'                         '🎉.txt'                  round-trip OK
    'my file.txt'                         'my file.txt'                    'my file.txt'             round-trip OK
    'name"quote.txt'                      '"name\\"quote.txt"'             'name\\"quote.txt'        round-trip OK  (git's pathspec unescaping does it)
    'back\slash.txt'                      '"back\\\\slash.txt"'            'back\\\\slash.txt'       round-trip OK  (same reason)
    'tab<TAB>here.txt'                    '"tab\\there.txt"'               'tab\\there.txt'          FAILS: pathspec did not match any files
    

    We tested the same set before the change: every high-byte name failed, so the flag fixes exactly the class the report is about and nothing regresses. The residual is names containing control characters, which keeps git_status output one step short of "a name a client can pass back" for them. Low severity next to the reported bug, and cheap to state as a known limit rather than to chase — but the four tests as written assert NON_ASCII_FILENAME in result, which would read as a stronger guarantee than the change provides.

    If the round-trip guarantee is wanted for the status path specifically, --porcelain -z never quotes anything:

    repo.git.status("--porcelain", "-z") -> ['?? 日本.txt<NUL>', '?? back\slash.txt<NUL>', '?? tab<TAB>here.txt<NUL>', ...]   (raw bytes, NUL separated)
    repo.git.status("--porcelain")       -> ['?? "日本.txt"', '?? "back\\\\slash.txt"', '?? "tab\\there.txt"', ...]  (quoted again)
    

    That is a representation change for a human-readable tool, so I am noting it as an option, not asking for it.

    Prompt, per CONTRIBUTING

    I did not send a diff or open a pull request. The prompt that describes the change I built and measured:

    In src/git/src/mcp_server_git/server.py, add a small helper that runs a git
    command with core.quotepath=false for that command only, and use it in the four
    tools that print paths: git_status, git_diff_unstaged, git_diff_staged and
    git_diff. Do not change git_show: it builds its paths from GitPython's Diff
    objects, which are already decoded. Never write the setting into the
    repository's config.
    
    Add one test per tool using a non-ASCII file name, with core.quotepath pinned to
    true in the test repository so the result does not depend on the user's global
    config, asserting that the raw name appears and that the octal escape sequence
    does not. Do not claim in the tests or the message that every path becomes
    passable back to git_add: with core.quotepath=false git still C-quotes names
    containing control characters. Run the full suite, ruff check, ruff format
    --check and pyright.
    

    How I verified it, so it can be replayed: the fix and the four ported tests are on SU-AN-coder/servers branch verify/5129-git-quotepath (base 38569bd5, one commit: +12/-4 in server.py, +64 tests) — evidence, not a proposed pull request. The two probes that produced every number above are on branch verify/5129-probes as src/git/probe-5129-paths.py (calls the four tools in-process over seven file names and re-checks the round-trip through git_add) and src/git/probe-5129-idiom.py (the GitPython spellings and the --porcelain variants); either one runs from src/git with uv run python <file> and prints what is quoted above.

    I read every line of the diff myself, and every number above comes from a run I did on this machine rather than from reading the source.

  2. added
    bugSomething isn't working
    server-gitReference implementation for the Git MCP server - src/git
    on Oct 19, 2026
  3. cliffhall commented on Oct 19, 2026

    @cliffhall
    Member

    Triage: Priority Medium (total 7)

    • Severity 3: git_status and git_diff_unstaged/git_diff_staged/git_diff print non-ASCII paths as quoted octal escapes (git's default core.quotepath), so a client cannot pass the printed name back to git_add.
    • Urgency 3: idle more than 90 days, or wanted soon
    • Bonuses: +1 bug label

    Still present on v2 (checked by a local reproduction against the current code).

    Board: Servers V2 (#43), Status Incoming, awaiting maintainer review (no milestone yet). Part of the backlog triage in #4875.

  4. cliffhall commented on Oct 20, 2026

    @cliffhall
    Member

    Prior work, from the PR backlog triage (#4877). These outside pull requests addressed this issue against the pre-refactor code:

    They are being closed as part of the backlog triage in #4875, since the repository now takes issues rather than outside PRs and their diffs predate the v2 refactor. They stay readable and serve as prototypes for the fix here. Thank you to their authors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingserver-gitReference implementation for the Git MCP server - src/gitv2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions