From d6cb3f2ddbab38ab539e56866d4a657fb12cb2ee Mon Sep 17 00:00:00 2001 From: Ewerton Silva Date: Tue, 9 Jun 2026 22:28:34 -0300 Subject: [PATCH] feat(git): add prompts (git-commit-message, git-summarize-changes) The git reference server only exposed Tools. This adds Prompts support (per CONTRIBUTING's invitation to demonstrate under-used protocol features), with two data-backed prompts that embed live repository state: - git-commit-message: reads the staged diff, asks for a Conventional Commits message - git-summarize-changes: reads the working-tree diff (or vs an optional target ref) Both reuse the existing git helpers, inheriting the flag-injection guards and validate_repo_path scoping. Includes tests and README docs. --- src/git/README.md | 17 +++++ src/git/src/mcp_server_git/server.py | 102 +++++++++++++++++++++++++++ src/git/tests/test_server.py | 58 +++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/src/git/README.md b/src/git/README.md index c9ec3140be..3b046abc0d 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -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) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 5ce953e545..4d46e5f4f3 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -6,6 +6,10 @@ from mcp.server.stdio import stdio_server from mcp.types import ( ClientCapabilities, + GetPromptResult, + Prompt, + PromptArgument, + PromptMessage, TextContent, Tool, ListRootsResult, @@ -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() @@ -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__) @@ -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) diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index a5492adc85..6ae0a7ff37 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -16,6 +16,8 @@ git_create_branch, git_show, validate_repo_path, + build_commit_message_prompt, + build_summarize_changes_prompt, ) import shutil @@ -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")