Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ Please note that mcp-server-git is currently in early development. The functiona
- `not_contains` (string, optional): The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified
- Returns: List of branches

14. `git_remote`
- Lists the repository's remotes with their fetch and push URLs, like `git remote -v`
- Inputs:
- `repo_path` (string): Path to the Git repository.
- Returns: One line per remote and direction, `<name>\t<url> (fetch)` or `(push)` (a partial clone's fetch line ends with its filter, such as ` [blob:none]`), or `No remotes configured`
- A credential embedded in a URL is never returned: the whole userinfo of a URL such as `https://user:token@host/...` or `https://token@host/...` is shown as `<redacted>`. An SSH username (`ssh://git@host/...`) is kept, since it names an account rather than authenticating one; a password after it is redacted. A remote-helper address (`https::https://user:token@host/...`) has its inner URL redacted the same way. scp-style addresses (`git@host:path`) and local paths are shown as configured. A line that cannot be parsed is shown as `<redacted>`, and if a configured remote URL (or `insteadOf` rewrite) contains a newline, which git itself refuses, the tool returns an error instead of a listing.

### Protocol versions

The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools behave the same in both.
Expand Down
130 changes: 130 additions & 0 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# logging), so nothing differs between them.

import logging
import re
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
Expand Down Expand Up @@ -72,6 +73,7 @@ class GitTools(str, Enum):
SHOW = "git_show"

BRANCH = "git_branch"
REMOTE = "git_remote"


def _git_text(repo: git.Repo, command: str, *args: str) -> str:
Expand Down Expand Up @@ -517,6 +519,118 @@ def git_branch(
return branch_info


# A URL with an authority part: `scheme://[userinfo@]host...`. The userinfo is
# everything up to the last '@' before the first '/', which is where git itself
# stops reading it.
_URL_USERINFO = re.compile(
r"^(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*)://(?P<userinfo>[^/]*)@"
)

# An explicit remote-helper address, `<transport>::<address>`
# (`https::https://user:token@host/...`, gitremote-helpers(7)). The address
# after the `::` is a URL of its own, and is redacted as one.
_REMOTE_HELPER = re.compile(r"^(?P<transport>[A-Za-z][A-Za-z0-9+.-]*)::")

# One line of `git remote -v`: `<name>\t<url> (fetch|push)`, and on a
# partial clone's fetch line the promisor filter after it (` [blob:none]`).
# `.` matches everything but '\n', so a URL holding another line separator
# (U+2028, say) stays whole.
_REMOTE_LINE = re.compile(
r"^(?P<name>[^\t]*)\t(?P<url>.*) \((?P<direction>fetch|push)\)"
r"(?P<filter> \[.*\])?$"
)

# Schemes whose username is an account name rather than a secret, so it is kept
# (`ssh://git@host/...`) and only a password after it is redacted.
_SSH_SCHEMES = frozenset({"ssh", "git+ssh", "ssh+git"})

REDACTED = "<redacted>"


def redact_url(url: str) -> str:
"""`url` with any credential in its userinfo replaced by `<redacted>` (#1611).

A remote URL can carry a credential (`https://user:token@host/...`), and
the tool's output goes to the model, so it must not echo one. Over HTTP(S)
and every other scheme a token is as often the username as the password
(`https://<token>@github.com/...`), so the whole userinfo is redacted. An
SSH username names the account and authenticates nothing, so it stays,
with only a password after it redacted. A remote-helper address
(`<transport>::<url>`) has its inner URL redacted. An scp-like address
(`git@host:path`) or a local path has no userinfo to carry a password and
is returned as is.
"""
helper = _REMOTE_HELPER.match(url)
if helper is not None:
return url[: helper.end()] + redact_url(url[helper.end() :])
match = _URL_USERINFO.match(url)
if match is None:
return url
scheme, userinfo = match["scheme"], match["userinfo"]
if scheme.lower() in _SSH_SCHEMES:
user, has_password, _ = userinfo.partition(":")
kept = f"{user}:{REDACTED}" if has_password else user
else:
kept = REDACTED
return f"{scheme}://{kept}@{url[match.end() :]}"


# The config values `git remote -v` builds its URLs from: each remote's
# `url` and `pushurl`, and the `insteadOf` / `pushInsteadOf` rewrites.
_REMOTE_URL_KEYS = r"^(remote\..*\.(url|pushurl)|url\..*\.(insteadof|pushinsteadof))$"


def _remote_url_has_newline(repo: git.Repo) -> bool:
"""Whether a value `git remote -v` prints from holds a newline.

Such a URL is split across lines of `git remote -v`, and a fragment can
look like a whole line (`https://alice:SECRET (fetch)`) with its
credential's `@` on the next one, so no line parser can redact it. Read
the raw values NUL-separated (`-z`: `<key>\\n<value>\\0`), where a newline
cannot hide. `with_exceptions=False` because `--get-regexp` exits 1 when
nothing matches; a real failure leaves the output empty, and the
`git remote -v` that follows reports it.
"""
entries = repo.git.config(
"-z", "--get-regexp", _REMOTE_URL_KEYS, with_exceptions=False
)
return any("\n" in entry.partition("\n")[2] for entry in entries.split("\0"))


def git_remote(repo: git.Repo) -> str:
"""The repository's remotes with their fetch and push URLs, as `git remote -v`.

Each line is `<name>\\t<url> (fetch|push)`, a partial clone's fetch line
followed by its promisor filter (` [blob:none]`); the URL goes through
`redact_url` so no credential reaches the client. Lines are split on
'\\n' alone, the only separator git prints, not on every separator
`str.splitlines` knows, which would cut a URL holding U+2028 in two. A
URL holding a newline itself is refused (`_remote_url_has_newline`).
"""
if _remote_url_has_newline(repo):
raise ValueError(
"A configured remote URL contains a newline, so the remotes cannot "
"be listed without risking a credential in it; git refuses such "
"URLs as well"
)
output = _git_text(repo, "remote", "-v").rstrip("\n")
if not output:
return "No remotes configured"
lines = []
for line in output.split("\n"):
match = _REMOTE_LINE.match(line)
if match is None:
# Not a line in the shape `git remote -v` prints (a URL holding a
# newline, say): it cannot be parsed, so none of it is returned.
lines.append(REDACTED)
continue
url = redact_url(match["url"])
lines.append(
f"{match['name']}\t{url} ({match['direction']}){match['filter'] or ''}"
)
return "\n".join(lines)


# The failures a tool reports to the model as a `ToolError`, each with the
# message it was raised with: the guards' `ValueError`s and
# `FlagInjectionError`s, git and GitPython errors (`GitError`: a failed
Expand Down Expand Up @@ -814,6 +928,22 @@ def branch(
) -> str:
return git_branch(open_repo(repo_path), branch_type, contains, not_contains)

@server.tool(
name=GitTools.REMOTE.value,
description=(
"Lists the repository's remotes with their fetch and push URLs, "
"like `git remote -v`. Credentials embedded in a URL are shown as "
"<redacted>"
),
annotations=READ_ONLY,
structured_output=False,
)
@reports_failures
def remote(
repo_path: Annotated[str, Field(description="The path to the Git repository.")],
) -> str:
return git_remote(open_repo(repo_path))

return server


Expand Down
24 changes: 24 additions & 0 deletions src/git/tests/snapshots/list_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,30 @@
"idempotentHint": true,
"openWorldHint": false
}
},
{
"name": "git_remote",
"description": "Lists the repository's remotes with their fetch and push URLs, like `git remote -v`. Credentials embedded in a URL are shown as <redacted>",
"inputSchema": {
"properties": {
"repo_path": {
"description": "The path to the Git repository.",
"title": "Repo Path",
"type": "string"
}
},
"required": [
"repo_path"
],
"type": "object",
"title": "remoteArguments"
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
]
}
1 change: 1 addition & 0 deletions src/git/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ async def test_list_tools_names_in_order():
"git_checkout",
"git_show",
"git_branch",
"git_remote",
]


Expand Down
Loading
Loading