Repository navigation
git_checkout reports "Switched to branch 'X'" when the checkout actually detached HEAD #4804
Description
What happens
git_checkout validates branch_name with repo.rev_parse(...), which resolves any revision — a commit sha, a tag, HEAD~1, a remote-tracking ref — not only branch names. It then checks out and returns a fixed string:
repo.rev_parse(branch_name) # Validates branch_name is a real git ref, throws BadName if not
repo.git.checkout(branch_name)
return f"Switched to branch '{branch_name}'"src/git/src/mcp_server_git/server.py lines 202-204 on main (the flag-injection guard above them is elided). The tool description is Switches branches, the parameter is branch_name, and the README says the same: "git_checkout … Returns: Confirmation of branch switch". For every revision that is not a branch, the checkout detaches HEAD and the reply still claims a branch switch.
Reproduction
mcp-server-git 2026.8.18, mcp 1.30.0, over stdio, on a fresh repo with three commits, a tag v1 and a branch feature:
branch_name "feature" -> Switched to branch 'feature' | On branch feature (correct)
branch_name "nonexistent" -> isError=true, repo unchanged (correct)
branch_name "49cea63" (a sha) -> Switched to branch '49cea63' | HEAD detached at 49cea63
branch_name "v1" (a tag) -> Switched to branch 'v1' | HEAD detached at v1
branch_name "HEAD~1" -> Switched to branch 'HEAD~1' | HEAD detached at f1de938
branch_name "origin/main" -> Switched to branch 'origin/main' | HEAD detached at origin/main
branch_name "refs/heads/feature" -> Switched to branch 'refs/heads/feature' | HEAD detached at refs/heads/feature
The last two are the realistic ones: an agent writes origin/main or a full ref name far more often than a raw sha.
Why this matters for the calling model
The reply is the only thing the model sees: content is a single TextContent, structuredContent is null, isError is false, the tool declares no outputSchema, and its annotations are static. Nothing in the response distinguishes an attached checkout from a detached one.
Git itself does distinguish them. git checkout <sha> writes the You are in 'detached HEAD' state advisory to stderr, and leaving a detached HEAD prints Warning: you are leaving 1 commit behind together with the git branch <new-branch-name> <sha> recipe. repo.git.checkout() returns '' and that advisory is discarded, so the server replaces git's own warning with a success sentence. Work committed from there is on no branch and reachable only through the reflog until gc expires it; the tool never says so, and nothing in its reply suggests calling git_status.
Suggested fix
Report the state rather than assume it:
repo.git.checkout(branch_name)
if repo.head.is_detached:
return f"HEAD is now detached at {repo.head.commit.hexsha[:7]}"
return f"Switched to branch '{repo.active_branch.name}'"Verified against main: src/git/tests/test_server.py stays at 47 passed, and git_checkout on a branch name still returns Switched to branch '<name>'. Rejecting non-branch revisions would also close it, but that removes a capability some callers use deliberately.
Same root cause as #4762 and #4763 — no verification of the outcome after the git call — so this could fold into the same fix if that is easier to review.
Activity
CryoThrust commentedon Sep 15, 2026
Confirmed on
main, and opened #4805 for it.One note that might be useful for whoever looks at #4762/#4763: the cause here is slightly different from those two even though the symptom family ("reports an outcome without verifying it") is the same. For commit/add, the check is missing before the git call — nothing was staged, so the operation is a no-op that should have been rejected. Here the check is missing after the call: the operation succeeded, it just produced a different state than the message claims. So the fix shape differs —
is_detachedright aftercheckout, rather than a precondition test — and folding them into one PR would mean two unrelated decision points in one diff. Happy to have it folded in if a maintainer prefers that, but I kept #4805 scoped to the checkout reply.I also took the branch name in the reply from
active_branchrather than echoing the argument, so the message cannot disagree with the ref that was actually checked out (relevant for therefs/heads/featurecase in your table).test(git): characterize serve() through the protocol, gate coverage p…
test(git): characterize serve() through the protocol, gate coverage p…
test(git): characterize serve() through the protocol, gate coverage p…
test(git): characterize serve() through the protocol, gate coverage p…
Merge pull request #5023 from modelcontextprotocol/v2/fix/4804-git-ch…
cliffhall commentedon Oct 5, 2026
Fixed by #5023, merged into
v2/main(Wave 6 of #5004).test(git): assert git_checkout reports a detached HEAD (modelcontextp…