Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ 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

### Prompts

In addition to tools, the server exposes prompts that assemble a ready-to-send message from real repository data:

1. `git-commit-message`
- Generates a [Conventional Commits](https://www.conventionalcommits.org/) message for the currently staged changes
- Arguments:
- `repo_path` (string, required): Path to Git repository
- Returns: A user message embedding the staged diff, with instructions to draft the commit message

2. `git-summarize-changes`
- Summarizes repository changes in plain language
- Arguments:
- `repo_path` (string, required): Path to Git repository
- `target` (string, optional): Branch or commit to diff against (defaults to unstaged working-tree changes)
- Returns: A user message embedding the diff, with instructions to summarize it

## Installation

### Using uv (recommended)
Expand Down
102 changes: 102 additions & 0 deletions src/git/src/mcp_server_git/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
from mcp.server.stdio import stdio_server
from mcp.types import (
ClientCapabilities,
GetPromptResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
Tool,
ListRootsResult,
Expand Down Expand Up @@ -108,6 +112,10 @@ class GitTools(str, Enum):

BRANCH = "git_branch"

class GitPrompts(str, Enum):
COMMIT_MESSAGE = "git-commit-message"
SUMMARIZE_CHANGES = "git-summarize-changes"

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

Expand Down Expand Up @@ -290,6 +298,38 @@ def git_branch(repo: git.Repo, branch_type: str, contains: str | None = None, no
return branch_info


def build_commit_message_prompt(repo: git.Repo) -> str:
diff = git_diff_staged(repo)
if not diff.strip():
return (
"There are no staged changes in this repository. Ask the user to stage "
"changes with `git add` before requesting a commit message."
)
return (
"Write a commit message for the following staged changes, following the "
"Conventional Commits specification (e.g. `feat:`, `fix:`, `docs:`, `refactor:`). "
"Use an imperative subject line under 72 characters, and add a short body only if it "
"adds useful context.\n\n"
f"Staged diff:\n```diff\n{diff}\n```"
)


def build_summarize_changes_prompt(repo: git.Repo, target: str | None = None) -> str:
if target:
diff = git_diff(repo, target)
scope = f"compared to `{target}`"
else:
diff = git_diff_unstaged(repo)
scope = "in the working tree (unstaged)"
if not diff.strip():
return f"There are no changes {scope} to summarize."
return (
f"Summarize the following changes {scope} in plain language for a reviewer. Group "
"related changes and explain the intent behind them, not just the modified lines.\n\n"
f"Diff:\n```diff\n{diff}\n```"
)


async def serve(repository: Path | None) -> None:
logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -582,6 +622,68 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
case _:
raise ValueError(f"Unknown tool: {name}")

@server.list_prompts()
async def list_prompts() -> list[Prompt]:
return [
Prompt(
name=GitPrompts.COMMIT_MESSAGE,
description="Generate a Conventional Commits message for the currently staged changes",
arguments=[
PromptArgument(
name="repo_path",
description="Path to the git repository",
required=True,
),
],
),
Prompt(
name=GitPrompts.SUMMARIZE_CHANGES,
description="Summarize repository changes in plain language (working tree, or compared to a target ref)",
arguments=[
PromptArgument(
name="repo_path",
description="Path to the git repository",
required=True,
),
PromptArgument(
name="target",
description="Optional branch or commit to diff against (defaults to unstaged working-tree changes)",
required=False,
),
],
),
]

@server.get_prompt()
async def get_prompt(name: str, arguments: dict[str, str] | None) -> GetPromptResult:
arguments = arguments or {}
if "repo_path" not in arguments:
raise ValueError("Missing required argument: repo_path")

repo_path = Path(arguments["repo_path"])
validate_repo_path(repo_path, repository)
repo = git.Repo(repo_path)

match name:
case GitPrompts.COMMIT_MESSAGE:
text = build_commit_message_prompt(repo)
description = "Draft a commit message for the staged changes"
case GitPrompts.SUMMARIZE_CHANGES:
text = build_summarize_changes_prompt(repo, arguments.get("target"))
description = "Summarize the repository changes"
case _:
raise ValueError(f"Unknown prompt: {name}")

return GetPromptResult(
description=description,
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text=text),
)
],
)

options = server.create_initialization_options()
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, options, raise_exceptions=True)
58 changes: 58 additions & 0 deletions src/git/tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
git_create_branch,
git_show,
validate_repo_path,
build_commit_message_prompt,
build_summarize_changes_prompt,
)
import shutil

Expand Down Expand Up @@ -482,3 +484,59 @@ def test_git_branch_rejects_contains_flag_injection(test_repository):

with pytest.raises(BadName):
git_branch(test_repository, "local", not_contains="--exec=evil")


# Tests for prompts (git-commit-message, git-summarize-changes)

def test_build_commit_message_prompt_includes_staged_diff(test_repository):
file_path = Path(test_repository.working_dir) / "feature.txt"
file_path.write_text("hello prompt world")
test_repository.index.add(["feature.txt"])

prompt = build_commit_message_prompt(test_repository)

assert "Conventional Commits" in prompt
assert "feature.txt" in prompt
assert "hello prompt world" in prompt # the staged diff is embedded in the prompt


def test_build_commit_message_prompt_no_staged_changes(test_repository):
prompt = build_commit_message_prompt(test_repository)

assert "no staged changes" in prompt.lower()


def test_build_summarize_changes_prompt_unstaged(test_repository):
file_path = Path(test_repository.working_dir) / "test.txt"
file_path.write_text("rewritten content")

prompt = build_summarize_changes_prompt(test_repository)

assert "working tree" in prompt
assert "rewritten content" in prompt


def test_build_summarize_changes_prompt_with_target(test_repository):
default_branch = test_repository.active_branch.name
test_repository.git.checkout("-b", "summary-branch")
file_path = Path(test_repository.working_dir) / "test.txt"
file_path.write_text("branch content")
test_repository.index.add(["test.txt"])
test_repository.index.commit("branch commit")

prompt = build_summarize_changes_prompt(test_repository, default_branch)

assert default_branch in prompt
assert "branch content" in prompt


def test_build_summarize_changes_prompt_no_changes(test_repository):
prompt = build_summarize_changes_prompt(test_repository)

assert "no changes" in prompt.lower()


def test_build_summarize_changes_prompt_rejects_flag_injection(test_repository):
"""The summarize prompt reuses git_diff, so it inherits the '-' guard."""
with pytest.raises(BadName):
build_summarize_changes_prompt(test_repository, "--output=/tmp/evil")
Loading