Skip to content

Commit 2713f3d

Browse files
Merge branch 'main' into patch-1
2 parents cf208f0 + 0f9f95a commit 2713f3d

9 files changed

Lines changed: 354 additions & 213 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ Official integrations are maintained by companies building production ready MCP
117117
- <img height="12" width="12" src="https://codelogic.com/wp-content/themes/codelogic/assets/img/favicon.png" alt="CodeLogic Logo" /> **[CodeLogic](https://github.com/CodeLogicIncEngineering/codelogic-mcp-server)** - Interact with [CodeLogic](https://codelogic.com), a Software Intelligence platform that graphs complex code and data architecture dependencies, to boost AI accuracy and insight.
118118
- <img height="12" width="12" src="https://static.coingecko.com/s/coingecko-logo-white-750bdea438e850281f784dffc8f4fd498415754f088d655a1140849745cb66ac.svg" alt="CoinGecko Logo" /> **[CoinGecko](https://github.com/coingecko/coingecko-typescript/tree/main/packages/mcp-server)** - Official [CoinGecko API](https://www.coingecko.com/en/api) MCP Server for Crypto Price & Market Data, across 200+ Blockchain Networks and 8M+ Tokens.
119119
- <img height="12" width="12" src="https://www.comet.com/favicon.ico" alt="Comet Logo" /> **[Comet Opik](https://github.com/comet-ml/opik-mcp)** - Query and analyze your [Opik](https://github.com/comet-ml/opik) logs, traces, prompts and all other telemtry data from your LLMs in natural language.
120+
- <img height="12" width="12" src="https://cdn.prod.website-files.com/6572bd8c27ee5db3eb91f4b3/6572bd8d27ee5db3eb91f55e_favicon-dashflow-webflow-template.svg" alt="OSS Conductor Logo" /> <img height="12" width="12" src="https://orkes.io/icons/icon-48x48.png" alt="Orkes Conductor Logo" />**[Conductor](https://github.com/conductor-oss/conductor-mcp)** - Interact with Conductor (OSS and Orkes) REST APIs.
120121
- <img height="12" width="12" src="https://www.confluent.io/favicon.ico" alt="Confluent Logo" /> **[Confluent](https://github.com/confluentinc/mcp-confluent)** - Interact with Confluent Kafka and Confluent Cloud REST APIs.
121122
- <img src="https://contrastsecurity.com/favicon.ico" alt="Contrast Security" width="12" height="12"> **[Contrast Security](https://github.com/Contrast-Security-OSS/mcp-contrast)** - Brings Contrast's vulnerability and SCA data into your coding agent to quickly remediate vulnerabilities.
122123
- <img height="12" width="12" src="https://www.convex.dev/favicon.ico" alt="Convex Logo" /> **[Convex](https://stack.convex.dev/convex-mcp-server)** - Introspect and query your apps deployed to Convex.

src/git/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,24 @@ Please note that mcp-server-git is currently in early development. The functiona
1616

1717
2. `git_diff_unstaged`
1818
- Shows changes in working directory not yet staged
19-
- Input:
19+
- Inputs:
2020
- `repo_path` (string): Path to Git repository
21+
- `context_lines` (number, optional): Number of context lines to show (default: 3)
2122
- Returns: Diff output of unstaged changes
2223

2324
3. `git_diff_staged`
2425
- Shows changes that are staged for commit
25-
- Input:
26+
- Inputs:
2627
- `repo_path` (string): Path to Git repository
28+
- `context_lines` (number, optional): Number of context lines to show (default: 3)
2729
- Returns: Diff output of staged changes
2830

2931
4. `git_diff`
3032
- Shows differences between branches or commits
3133
- Inputs:
3234
- `repo_path` (string): Path to Git repository
3335
- `target` (string): Target branch or commit to compare with
36+
- `context_lines` (number, optional): Number of context lines to show (default: 3)
3437
- Returns: Diff output comparing current state with target
3538

3639
5. `git_commit`
@@ -85,6 +88,15 @@ Please note that mcp-server-git is currently in early development. The functiona
8588
- `repo_path` (string): Path to directory to initialize git repo
8689
- Returns: Confirmation of repository initialization
8790

91+
13. `git_branch`
92+
- List Git branches
93+
- Inputs:
94+
- `repo_path` (string): Path to the Git repository.
95+
- `branch_type` (string): Whether to list local branches ('local'), remote branches ('remote') or all branches('all').
96+
- `contains` (string, optional): The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified
97+
- `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
98+
- Returns: List of branches
99+
88100
## Installation
89101

90102
### Using uv (recommended)

src/git/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ dev-dependencies = ["pyright>=1.1.389", "ruff>=0.7.3", "pytest>=8.0.0"]
3636
testpaths = ["tests"]
3737
python_files = "test_*.py"
3838
python_classes = "Test*"
39-
python_functions = "test_*"
39+
python_functions = "test_*"

src/git/src/mcp_server_git/py.typed

Whitespace-only changes.

src/git/src/mcp_server_git/server.py

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22
from pathlib import Path
3-
from typing import Sequence
3+
from typing import Sequence, Optional
44
from mcp.server import Server
55
from mcp.server.session import ServerSession
66
from mcp.server.stdio import stdio_server
@@ -13,20 +13,26 @@
1313
)
1414
from enum import Enum
1515
import git
16-
from pydantic import BaseModel
16+
from pydantic import BaseModel, Field
17+
18+
# Default number of context lines to show in diff output
19+
DEFAULT_CONTEXT_LINES = 3
1720

1821
class GitStatus(BaseModel):
1922
repo_path: str
2023

2124
class GitDiffUnstaged(BaseModel):
2225
repo_path: str
26+
context_lines: int = DEFAULT_CONTEXT_LINES
2327

2428
class GitDiffStaged(BaseModel):
2529
repo_path: str
30+
context_lines: int = DEFAULT_CONTEXT_LINES
2631

2732
class GitDiff(BaseModel):
2833
repo_path: str
2934
target: str
35+
context_lines: int = DEFAULT_CONTEXT_LINES
3036

3137
class GitCommit(BaseModel):
3238
repo_path: str
@@ -59,6 +65,24 @@ class GitShow(BaseModel):
5965
class GitInit(BaseModel):
6066
repo_path: str
6167

68+
class GitBranch(BaseModel):
69+
repo_path: str = Field(
70+
...,
71+
description="The path to the Git repository.",
72+
)
73+
branch_type: str = Field(
74+
...,
75+
description="Whether to list local branches ('local'), remote branches ('remote') or all branches('all').",
76+
)
77+
contains: Optional[str] = Field(
78+
None,
79+
description="The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified",
80+
)
81+
not_contains: Optional[str] = Field(
82+
None,
83+
description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified",
84+
)
85+
6286
class GitTools(str, Enum):
6387
STATUS = "git_status"
6488
DIFF_UNSTAGED = "git_diff_unstaged"
@@ -72,18 +96,19 @@ class GitTools(str, Enum):
7296
CHECKOUT = "git_checkout"
7397
SHOW = "git_show"
7498
INIT = "git_init"
99+
BRANCH = "git_branch"
75100

76101
def git_status(repo: git.Repo) -> str:
77102
return repo.git.status()
78103

79-
def git_diff_unstaged(repo: git.Repo) -> str:
80-
return repo.git.diff()
104+
def git_diff_unstaged(repo: git.Repo, context_lines: int = DEFAULT_CONTEXT_LINES) -> str:
105+
return repo.git.diff(f"--unified={context_lines}")
81106

82-
def git_diff_staged(repo: git.Repo) -> str:
83-
return repo.git.diff("--cached")
107+
def git_diff_staged(repo: git.Repo, context_lines: int = DEFAULT_CONTEXT_LINES) -> str:
108+
return repo.git.diff(f"--unified={context_lines}", "--cached")
84109

85-
def git_diff(repo: git.Repo, target: str) -> str:
86-
return repo.git.diff(target)
110+
def git_diff(repo: git.Repo, target: str, context_lines: int = DEFAULT_CONTEXT_LINES) -> str:
111+
return repo.git.diff(f"--unified={context_lines}", target)
87112

88113
def git_commit(repo: git.Repo, message: str) -> str:
89114
commit = repo.index.commit(message)
@@ -102,16 +127,16 @@ def git_log(repo: git.Repo, max_count: int = 10) -> list[str]:
102127
log = []
103128
for commit in commits:
104129
log.append(
105-
f"Commit: {commit.hexsha}\n"
106-
f"Author: {commit.author}\n"
130+
f"Commit: {commit.hexsha!r}\n"
131+
f"Author: {commit.author!r}\n"
107132
f"Date: {commit.authored_datetime}\n"
108-
f"Message: {commit.message}\n"
133+
f"Message: {commit.message!r}\n"
109134
)
110135
return log
111136

112137
def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None = None) -> str:
113138
if base_branch:
114-
base = repo.refs[base_branch]
139+
base = repo.references[base_branch]
115140
else:
116141
base = repo.active_branch
117142

@@ -132,10 +157,10 @@ def git_init(repo_path: str) -> str:
132157
def git_show(repo: git.Repo, revision: str) -> str:
133158
commit = repo.commit(revision)
134159
output = [
135-
f"Commit: {commit.hexsha}\n"
136-
f"Author: {commit.author}\n"
137-
f"Date: {commit.authored_datetime}\n"
138-
f"Message: {commit.message}\n"
160+
f"Commit: {commit.hexsha!r}\n"
161+
f"Author: {commit.author!r}\n"
162+
f"Date: {commit.authored_datetime!r}\n"
163+
f"Message: {commit.message!r}\n"
139164
]
140165
if commit.parents:
141166
parent = commit.parents[0]
@@ -147,6 +172,34 @@ def git_show(repo: git.Repo, revision: str) -> str:
147172
output.append(d.diff.decode('utf-8'))
148173
return "".join(output)
149174

175+
def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str:
176+
match contains:
177+
case None:
178+
contains_sha = (None,)
179+
case _:
180+
contains_sha = ("--contains", contains)
181+
182+
match not_contains:
183+
case None:
184+
not_contains_sha = (None,)
185+
case _:
186+
not_contains_sha = ("--no-contains", not_contains)
187+
188+
match branch_type:
189+
case 'local':
190+
b_type = None
191+
case 'remote':
192+
b_type = "-r"
193+
case 'all':
194+
b_type = "-a"
195+
case _:
196+
return f"Invalid branch type: {branch_type}"
197+
198+
# None value will be auto deleted by GitPython
199+
branch_info = repo.git.branch(b_type, *contains_sha, *not_contains_sha)
200+
201+
return branch_info
202+
150203
async def serve(repository: Path | None) -> None:
151204
logger = logging.getLogger(__name__)
152205

@@ -166,62 +219,67 @@ async def list_tools() -> list[Tool]:
166219
Tool(
167220
name=GitTools.STATUS,
168221
description="Shows the working tree status",
169-
inputSchema=GitStatus.schema(),
222+
inputSchema=GitStatus.model_json_schema(),
170223
),
171224
Tool(
172225
name=GitTools.DIFF_UNSTAGED,
173226
description="Shows changes in the working directory that are not yet staged",
174-
inputSchema=GitDiffUnstaged.schema(),
227+
inputSchema=GitDiffUnstaged.model_json_schema(),
175228
),
176229
Tool(
177230
name=GitTools.DIFF_STAGED,
178231
description="Shows changes that are staged for commit",
179-
inputSchema=GitDiffStaged.schema(),
232+
inputSchema=GitDiffStaged.model_json_schema(),
180233
),
181234
Tool(
182235
name=GitTools.DIFF,
183236
description="Shows differences between branches or commits",
184-
inputSchema=GitDiff.schema(),
237+
inputSchema=GitDiff.model_json_schema(),
185238
),
186239
Tool(
187240
name=GitTools.COMMIT,
188241
description="Records changes to the repository",
189-
inputSchema=GitCommit.schema(),
242+
inputSchema=GitCommit.model_json_schema(),
190243
),
191244
Tool(
192245
name=GitTools.ADD,
193246
description="Adds file contents to the staging area",
194-
inputSchema=GitAdd.schema(),
247+
inputSchema=GitAdd.model_json_schema(),
195248
),
196249
Tool(
197250
name=GitTools.RESET,
198251
description="Unstages all staged changes",
199-
inputSchema=GitReset.schema(),
252+
inputSchema=GitReset.model_json_schema(),
200253
),
201254
Tool(
202255
name=GitTools.LOG,
203256
description="Shows the commit logs",
204-
inputSchema=GitLog.schema(),
257+
inputSchema=GitLog.model_json_schema(),
205258
),
206259
Tool(
207260
name=GitTools.CREATE_BRANCH,
208261
description="Creates a new branch from an optional base branch",
209-
inputSchema=GitCreateBranch.schema(),
262+
inputSchema=GitCreateBranch.model_json_schema(),
210263
),
211264
Tool(
212265
name=GitTools.CHECKOUT,
213266
description="Switches branches",
214-
inputSchema=GitCheckout.schema(),
267+
inputSchema=GitCheckout.model_json_schema(),
215268
),
216269
Tool(
217270
name=GitTools.SHOW,
218271
description="Shows the contents of a commit",
219-
inputSchema=GitShow.schema(),
272+
inputSchema=GitShow.model_json_schema(),
220273
),
221274
Tool(
222275
name=GitTools.INIT,
223276
description="Initialize a new Git repository",
224-
inputSchema=GitInit.schema(),
277+
inputSchema=GitInit.model_json_schema(),
278+
),
279+
Tool(
280+
name=GitTools.BRANCH,
281+
description="List Git branches",
282+
inputSchema=GitBranch.model_json_schema(),
225283
)
226284
]
227285

@@ -278,21 +336,21 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
278336
)]
279337

280338
case GitTools.DIFF_UNSTAGED:
281-
diff = git_diff_unstaged(repo)
339+
diff = git_diff_unstaged(repo, arguments.get("context_lines", DEFAULT_CONTEXT_LINES))
282340
return [TextContent(
283341
type="text",
284342
text=f"Unstaged changes:\n{diff}"
285343
)]
286344

287345
case GitTools.DIFF_STAGED:
288-
diff = git_diff_staged(repo)
346+
diff = git_diff_staged(repo, arguments.get("context_lines", DEFAULT_CONTEXT_LINES))
289347
return [TextContent(
290348
type="text",
291349
text=f"Staged changes:\n{diff}"
292350
)]
293351

294352
case GitTools.DIFF:
295-
diff = git_diff(repo, arguments["target"])
353+
diff = git_diff(repo, arguments["target"], arguments.get("context_lines", DEFAULT_CONTEXT_LINES))
296354
return [TextContent(
297355
type="text",
298356
text=f"Diff with {arguments['target']}:\n{diff}"
@@ -351,6 +409,18 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
351409
text=result
352410
)]
353411

412+
case GitTools.BRANCH:
413+
result = git_branch(
414+
repo,
415+
arguments.get("branch_type", 'local'),
416+
arguments.get("contains", None),
417+
arguments.get("not_contains", None),
418+
)
419+
return [TextContent(
420+
type="text",
421+
text=result
422+
)]
423+
354424
case _:
355425
raise ValueError(f"Unknown tool: {name}")
356426

src/git/tests/test_server.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
from pathlib import Path
33
import git
4-
from mcp_server_git.server import git_checkout
4+
from mcp_server_git.server import git_checkout, git_branch
55
import shutil
66

77
@pytest.fixture
@@ -27,4 +27,44 @@ def test_git_checkout_existing_branch(test_repository):
2727
def test_git_checkout_nonexistent_branch(test_repository):
2828

2929
with pytest.raises(git.GitCommandError):
30-
git_checkout(test_repository, "nonexistent-branch")
30+
git_checkout(test_repository, "nonexistent-branch")
31+
32+
def test_git_branch_local(test_repository):
33+
test_repository.git.branch("new-branch-local")
34+
result = git_branch(test_repository, "local")
35+
assert "new-branch-local" in result
36+
37+
def test_git_branch_remote(test_repository):
38+
# GitPython does not easily support creating remote branches without a remote.
39+
# This test will check the behavior when 'remote' is specified without actual remotes.
40+
result = git_branch(test_repository, "remote")
41+
assert "" == result.strip() # Should be empty if no remote branches
42+
43+
def test_git_branch_all(test_repository):
44+
test_repository.git.branch("new-branch-all")
45+
result = git_branch(test_repository, "all")
46+
assert "new-branch-all" in result
47+
48+
def test_git_branch_contains(test_repository):
49+
# Create a new branch and commit to it
50+
test_repository.git.checkout("-b", "feature-branch")
51+
Path(test_repository.working_dir / Path("feature.txt")).write_text("feature content")
52+
test_repository.index.add(["feature.txt"])
53+
commit = test_repository.index.commit("feature commit")
54+
test_repository.git.checkout("master")
55+
56+
result = git_branch(test_repository, "local", contains=commit.hexsha)
57+
assert "feature-branch" in result
58+
assert "master" not in result
59+
60+
def test_git_branch_not_contains(test_repository):
61+
# Create a new branch and commit to it
62+
test_repository.git.checkout("-b", "another-feature-branch")
63+
Path(test_repository.working_dir / Path("another_feature.txt")).write_text("another feature content")
64+
test_repository.index.add(["another_feature.txt"])
65+
commit = test_repository.index.commit("another feature commit")
66+
test_repository.git.checkout("master")
67+
68+
result = git_branch(test_repository, "local", not_contains=commit.hexsha)
69+
assert "another-feature-branch" not in result
70+
assert "master" in result

0 commit comments

Comments
 (0)