Skip to content

Commit 17a2be2

Browse files
authored
Merge branch 'main' into new-everything-server
2 parents c6daef7 + a37158b commit 17a2be2

File tree

3 files changed

+212
-4
lines changed

3 files changed

+212
-4
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ Official integrations are maintained by companies building production ready MCP
350350
- <img height="12" width="12" src="https://console.gomomento.com/favicon.ico" /> **[Momento](https://github.com/momentohq/mcp-momento)** - Momento Cache lets you quickly improve your performance, reduce costs, and handle load at any scale.
351351
- <img height="12" width="12" src="https://www.monday.com/favicon.ico" alt="Monday.com Logo" /> **[Monday.com](https://github.com/mondaycom/mcp)** - Interact with Monday.com boards, items, accounts and work forms.
352352
- <img height="12" width="12" src="https://www.mongodb.com/favicon.ico" /> **[MongoDB](https://github.com/mongodb-js/mongodb-mcp-server)** - Both MongoDB Community Server and MongoDB Atlas are supported.
353-
- <img height="12" width="12" src="https://moorcheh.ai/Moorcheh-mcp.ico" alt="Moorcheh Logo" /> **[Moorcheh](https://github.com/moorcheh-ai/moorcheh-mcp)** - Embed, store, and search your documents, and build secure chatbots and RAG systems with Moorcheh's information-theoretic semantic search engine
353+
- <img height="12" width="12" src="https://moorcheh.ai/Moorcheh-mcp.ico" alt="Moorcheh Logo" /> **[Moorcheh](https://github.com/moorcheh-ai/moorcheh-mcp)** - Provides seamless integration with Moorcheh's Embedding, Vector Store, Search, and Gen AI Answer services.
354354
- <img height="12" width="12" src="https://www.motherduck.com/favicon.ico" alt="MotherDuck Logo" /> **[MotherDuck](https://github.com/motherduckdb/mcp-server-motherduck)** - Query and analyze data with MotherDuck and local DuckDB
355355
- <img height="12" width="12" src="https://docs.mulesoft.com/_/img/favicon.ico" alt="Mulesoft Logo" /> **[Mulesoft](https://www.npmjs.com/package/@mulesoft/mcp-server)** - Build, deploy, and manage MuleSoft applications with natural language, directly inside any compatible IDE.
356356
- <img height="12" width="12" src="https://www.multiplayer.app/favicon-32x32.png" alt="Multiplayer Logo" /> **[Multiplayer](https://www.multiplayer.app/docs/ai/mcp-server)** - Analyze your full stack session recordings easily. Record a bug with Multiplayer, analyze and fix it with LLM
@@ -516,7 +516,7 @@ Official integrations are maintained by companies building production ready MCP
516516
- <img height="12" width="12" src="https://www.todoist.com/static/favicon-32x32.png" alt="Todoist Logo" /> **[Todoist](https://github.com/doist/todoist-ai)** - Search, add, and update [Todoist](https://todoist.com) tasks, projects, sections, comments, and more.
517517
- <img height="12" width="12" src="https://cdn.tokenmetrics.com/logo.svg" alt="Token Metrics Logo" /> **[Token Metrics](https://github.com/token-metrics/mcp)** - [Token Metrics](https://www.tokenmetrics.com/) integration for fetching real-time crypto market data, trading signals, price predictions, and advanced analytics.
518518
- <img height="12" width="12" src="https://di8m9w6rqrh5d.cloudfront.net/2G3TRwfv1w3GTLfmT7Dmco1VddoFTI5P/1920_6b7e7ec2-d897-4cd7-94f3-46a8301212c3.png" alt="TomTom Logo" /> **[TomTom-MCP](https://github.com/tomtom-international/tomtom-mcp)** - The [TomTom](https://www.tomtom.com/) MCP Server simplifies geospatial development by providing seamless access to TomTom's location services, including search, routing, traffic and static maps data.
519-
- <img height="12" width="12" src="https://images.thetradeagent.ai/trade_agent/logo.svg" alt="Trade Agent Logo" /> **[Trade Agent](https://github.com/Trade-Agent/trade-agent-mcp)** - Execute stock and crypto trades on your brokerage via [Trade Agent](https://thetradeagent.ai)
519+
- <img height="12" width="12" src="https://images.tradeit.app/trade_agent/logo.svg" alt="Trade It Logo" /> **[Trade It](https://github.com/trade-it-inc/trade-it-mcp)** - Execute stock, crypto, and options trades on your brokerage via [Trade It](https://tradeit.app). Supports Robinhood, ETrade, Charles Schwab, Webull, Coinbase, and Kraken.
520520
- <img height="18" width="18" src="https://github.com/twelvedata/mcp/raw/develop/favicon.ico" alt="Twelvedata Logo" /> **[Twelve Data](https://github.com/twelvedata/mcp)** — Integrate your AI agents with real-time and historical financial market data through our official [Twelve Data](https://twelvedata.com) MCP server.
521521
- <img height="12" width="12" src="https://www.twilio.com/content/dam/twilio-com/core-assets/social/favicon-16x16.png" alt="Twilio Logo" /> **[Twilio](https://github.com/twilio-labs/mcp)** - Interact with [Twilio](https://www.twilio.com/en-us) APIs to send SMS messages, manage phone numbers, configure your account, and more.
522522
- <img height="12" width="12" src="https://miniprogram.tcsas-superapp.com/icon_512.png" alt="TCSAS Logo" /> **[TCSAS](https://github.com/TCMPP-Team/tcsas-devtools-mcp-server)** - Built on the Tencent Mini Program technical framework and fully following the development, powered by [Tencent Cloud Super App as a Service](https://www.tencentcloud.com/products/tcsas?lang=en&pg=).

src/git/src/mcp_server_git/server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ def git_diff_staged(repo: git.Repo, context_lines: int = DEFAULT_CONTEXT_LINES)
116116
return repo.git.diff(f"--unified={context_lines}", "--cached")
117117

118118
def git_diff(repo: git.Repo, target: str, context_lines: int = DEFAULT_CONTEXT_LINES) -> str:
119+
# Defense in depth: reject targets starting with '-' to prevent flag injection,
120+
# even if a malicious ref with that name exists (e.g. via filesystem manipulation)
121+
if target.startswith("-"):
122+
raise git.exc.BadName(f"Invalid target: '{target}' - cannot start with '-'")
123+
repo.rev_parse(target) # Validates target is a real git ref, throws BadName if not
119124
return repo.git.diff(f"--unified={context_lines}", target)
120125

121126
def git_commit(repo: git.Repo, message: str) -> str:
@@ -179,6 +184,11 @@ def git_create_branch(repo: git.Repo, branch_name: str, base_branch: str | None
179184
return f"Created branch '{branch_name}' from '{base.name}'"
180185

181186
def git_checkout(repo: git.Repo, branch_name: str) -> str:
187+
# Defense in depth: reject branch names starting with '-' to prevent flag injection,
188+
# even if a malicious ref with that name exists (e.g. via filesystem manipulation)
189+
if branch_name.startswith("-"):
190+
raise git.exc.BadName(f"Invalid branch name: '{branch_name}' - cannot start with '-'")
191+
repo.rev_parse(branch_name) # Validates branch_name is a real git ref, throws BadName if not
182192
repo.git.checkout(branch_name)
183193
return f"Switched to branch '{branch_name}'"
184194

@@ -207,6 +217,27 @@ def git_show(repo: git.Repo, revision: str) -> str:
207217
output.append(d.diff)
208218
return "".join(output)
209219

220+
def validate_repo_path(repo_path: Path, allowed_repository: Path | None) -> None:
221+
"""Validate that repo_path is within the allowed repository path."""
222+
if allowed_repository is None:
223+
return # No restriction configured
224+
225+
# Resolve both paths to handle symlinks and relative paths
226+
try:
227+
resolved_repo = repo_path.resolve()
228+
resolved_allowed = allowed_repository.resolve()
229+
except (OSError, RuntimeError):
230+
raise ValueError(f"Invalid path: {repo_path}")
231+
232+
# Check if repo_path is the same as or a subdirectory of allowed_repository
233+
try:
234+
resolved_repo.relative_to(resolved_allowed)
235+
except ValueError:
236+
raise ValueError(
237+
f"Repository path '{repo_path}' is outside the allowed repository '{allowed_repository}'"
238+
)
239+
240+
210241
def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, not_contains: str | None = None) -> str:
211242
match contains:
212243
case None:
@@ -349,6 +380,9 @@ def by_commandline() -> Sequence[str]:
349380
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
350381
repo_path = Path(arguments["repo_path"])
351382

383+
# Validate repo_path is within allowed repository
384+
validate_repo_path(repo_path, repository)
385+
352386
# For all commands, we need an existing repo
353387
repo = git.Repo(repo_path)
354388

src/git/tests/test_server.py

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
git_reset,
1414
git_log,
1515
git_create_branch,
16-
git_show
16+
git_show,
17+
validate_repo_path,
1718
)
1819
import shutil
1920

@@ -39,7 +40,7 @@ def test_git_checkout_existing_branch(test_repository):
3940

4041
def test_git_checkout_nonexistent_branch(test_repository):
4142

42-
with pytest.raises(git.GitCommandError):
43+
with pytest.raises(git.exc.BadName):
4344
git_checkout(test_repository, "nonexistent-branch")
4445

4546
def test_git_branch_local(test_repository):
@@ -248,3 +249,176 @@ def test_git_show_initial_commit(test_repository):
248249
assert "Commit:" in result
249250
assert "initial commit" in result
250251
assert "test.txt" in result
252+
253+
254+
# Tests for validate_repo_path (repository scoping security fix)
255+
256+
def test_validate_repo_path_no_restriction():
257+
"""When no repository restriction is configured, any path should be allowed."""
258+
validate_repo_path(Path("/any/path"), None) # Should not raise
259+
260+
261+
def test_validate_repo_path_exact_match(tmp_path: Path):
262+
"""When repo_path exactly matches allowed_repository, validation should pass."""
263+
allowed = tmp_path / "repo"
264+
allowed.mkdir()
265+
validate_repo_path(allowed, allowed) # Should not raise
266+
267+
268+
def test_validate_repo_path_subdirectory(tmp_path: Path):
269+
"""When repo_path is a subdirectory of allowed_repository, validation should pass."""
270+
allowed = tmp_path / "repo"
271+
allowed.mkdir()
272+
subdir = allowed / "subdir"
273+
subdir.mkdir()
274+
validate_repo_path(subdir, allowed) # Should not raise
275+
276+
277+
def test_validate_repo_path_outside_allowed(tmp_path: Path):
278+
"""When repo_path is outside allowed_repository, validation should raise ValueError."""
279+
allowed = tmp_path / "allowed_repo"
280+
allowed.mkdir()
281+
outside = tmp_path / "other_repo"
282+
outside.mkdir()
283+
284+
with pytest.raises(ValueError) as exc_info:
285+
validate_repo_path(outside, allowed)
286+
assert "outside the allowed repository" in str(exc_info.value)
287+
288+
289+
def test_validate_repo_path_traversal_attempt(tmp_path: Path):
290+
"""Path traversal attempts (../) should be caught and rejected."""
291+
allowed = tmp_path / "allowed_repo"
292+
allowed.mkdir()
293+
# Attempt to escape via ../
294+
traversal_path = allowed / ".." / "other_repo"
295+
296+
with pytest.raises(ValueError) as exc_info:
297+
validate_repo_path(traversal_path, allowed)
298+
assert "outside the allowed repository" in str(exc_info.value)
299+
300+
301+
def test_validate_repo_path_symlink_escape(tmp_path: Path):
302+
"""Symlinks pointing outside allowed_repository should be rejected."""
303+
allowed = tmp_path / "allowed_repo"
304+
allowed.mkdir()
305+
outside = tmp_path / "outside"
306+
outside.mkdir()
307+
308+
# Create a symlink inside allowed that points outside
309+
symlink = allowed / "escape_link"
310+
symlink.symlink_to(outside)
311+
312+
with pytest.raises(ValueError) as exc_info:
313+
validate_repo_path(symlink, allowed)
314+
assert "outside the allowed repository" in str(exc_info.value)
315+
# Tests for argument injection protection
316+
317+
def test_git_diff_rejects_flag_injection(test_repository):
318+
"""git_diff should reject flags that could be used for argument injection."""
319+
with pytest.raises(git.exc.BadName):
320+
git_diff(test_repository, "--output=/tmp/evil")
321+
322+
with pytest.raises(git.exc.BadName):
323+
git_diff(test_repository, "--help")
324+
325+
with pytest.raises(git.exc.BadName):
326+
git_diff(test_repository, "-p")
327+
328+
329+
def test_git_checkout_rejects_flag_injection(test_repository):
330+
"""git_checkout should reject flags that could be used for argument injection."""
331+
with pytest.raises(git.exc.BadName):
332+
git_checkout(test_repository, "--help")
333+
334+
with pytest.raises(git.exc.BadName):
335+
git_checkout(test_repository, "--orphan=evil")
336+
337+
with pytest.raises(git.exc.BadName):
338+
git_checkout(test_repository, "-f")
339+
340+
341+
def test_git_diff_allows_valid_refs(test_repository):
342+
"""git_diff should work normally with valid git refs."""
343+
# Get the default branch name
344+
default_branch = test_repository.active_branch.name
345+
346+
# Create a branch with a commit for diffing
347+
test_repository.git.checkout("-b", "valid-diff-branch")
348+
file_path = Path(test_repository.working_dir) / "test.txt"
349+
file_path.write_text("valid diff content")
350+
test_repository.index.add(["test.txt"])
351+
test_repository.index.commit("valid diff commit")
352+
353+
# Test with branch name
354+
result = git_diff(test_repository, default_branch)
355+
assert "test.txt" in result
356+
357+
# Test with HEAD~1
358+
result = git_diff(test_repository, "HEAD~1")
359+
assert "test.txt" in result
360+
361+
# Test with commit hash
362+
commit_sha = test_repository.head.commit.hexsha
363+
result = git_diff(test_repository, commit_sha)
364+
assert result is not None
365+
366+
367+
def test_git_checkout_allows_valid_branches(test_repository):
368+
"""git_checkout should work normally with valid branch names."""
369+
# Get the default branch name
370+
default_branch = test_repository.active_branch.name
371+
372+
# Create a branch to checkout
373+
test_repository.git.branch("valid-checkout-branch")
374+
375+
result = git_checkout(test_repository, "valid-checkout-branch")
376+
assert "Switched to branch 'valid-checkout-branch'" in result
377+
assert test_repository.active_branch.name == "valid-checkout-branch"
378+
379+
# Checkout back to default branch
380+
result = git_checkout(test_repository, default_branch)
381+
assert "Switched to branch" in result
382+
assert test_repository.active_branch.name == default_branch
383+
384+
385+
def test_git_diff_rejects_malicious_refs(test_repository):
386+
"""git_diff should reject refs starting with '-' even if they exist.
387+
388+
This tests defense in depth against an attacker who creates malicious
389+
refs via filesystem manipulation (e.g. using mcp-filesystem to write
390+
to .git/refs/heads/--output=...).
391+
"""
392+
import os
393+
394+
# Manually create a malicious ref by writing directly to .git/refs
395+
sha = test_repository.head.commit.hexsha
396+
refs_dir = Path(test_repository.git_dir) / "refs" / "heads"
397+
malicious_ref_path = refs_dir / "--output=evil.txt"
398+
malicious_ref_path.write_text(sha)
399+
400+
# Even though the ref exists, it should be rejected
401+
with pytest.raises(git.exc.BadName):
402+
git_diff(test_repository, "--output=evil.txt")
403+
404+
# Verify no file was created (the attack was blocked)
405+
assert not os.path.exists("evil.txt")
406+
407+
# Cleanup
408+
malicious_ref_path.unlink()
409+
410+
411+
def test_git_checkout_rejects_malicious_refs(test_repository):
412+
"""git_checkout should reject refs starting with '-' even if they exist."""
413+
# Manually create a malicious ref
414+
sha = test_repository.head.commit.hexsha
415+
refs_dir = Path(test_repository.git_dir) / "refs" / "heads"
416+
malicious_ref_path = refs_dir / "--orphan=evil"
417+
malicious_ref_path.write_text(sha)
418+
419+
# Even though the ref exists, it should be rejected
420+
with pytest.raises(git.exc.BadName):
421+
git_checkout(test_repository, "--orphan=evil")
422+
423+
# Cleanup
424+
malicious_ref_path.unlink()

0 commit comments

Comments
 (0)