From 825251a6b97cfa01e41605e0ab07fc36d7133dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=2E=20Martins?= Date: Tue, 11 Nov 2025 19:20:45 +1100 Subject: [PATCH 1/2] feat(git): add signed commit tool with GPG support - Add git_commit_signed tool that supports GPG signing of commits - Accepts optional key_id parameter to sign with specific GPG key - Uses git commit -S flag for signing (works with default or specified key) - Add comprehensive tests for signed commits - Update GitTools enum and tool registration --- src/git/src/mcp_server_git/server.py | 36 ++++++++++++++++++++++++ src/git/tests/test_server.py | 41 +++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 9950da66ea..2da718ff87 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -38,6 +38,14 @@ class GitCommit(BaseModel): repo_path: str message: str +class GitCommitSigned(BaseModel): + repo_path: str + message: str + key_id: Optional[str] = Field( + None, + description="Optional GPG key ID to use for signing. If not provided, uses the default configured GPG key." + ) + class GitAdd(BaseModel): repo_path: str files: list[str] @@ -97,6 +105,7 @@ class GitTools(str, Enum): DIFF_STAGED = "git_diff_staged" DIFF = "git_diff" COMMIT = "git_commit" + COMMIT_SIGNED = "git_commit_signed" ADD = "git_add" RESET = "git_reset" LOG = "git_log" @@ -122,6 +131,17 @@ def git_commit(repo: git.Repo, message: str) -> str: commit = repo.index.commit(message) return f"Changes committed successfully with hash {commit.hexsha}" +def git_commit_signed(repo: git.Repo, message: str, key_id: str | None = None) -> str: + # Use the git command directly for signing support + if key_id: + repo.git.commit("-S" + key_id, "-m", message) + else: + repo.git.commit("-S", "-m", message) + + # Get the commit hash of HEAD + commit_hash = repo.head.commit.hexsha + return f"Changes committed and signed successfully with hash {commit_hash}" + def git_add(repo: git.Repo, files: list[str]) -> str: if files == ["."]: repo.git.add(".") @@ -272,6 +292,11 @@ async def list_tools() -> list[Tool]: description="Records changes to the repository", inputSchema=GitCommit.model_json_schema(), ), + Tool( + name=GitTools.COMMIT_SIGNED, + description="Records changes to the repository with GPG signature", + inputSchema=GitCommitSigned.model_json_schema(), + ), Tool( name=GitTools.ADD, description="Adds file contents to the staging area", @@ -383,6 +408,17 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: text=result )] + case GitTools.COMMIT_SIGNED: + result = git_commit_signed( + repo, + arguments["message"], + arguments.get("key_id") + ) + return [TextContent( + type="text", + text=result + )] + case GitTools.ADD: result = git_add(repo, arguments["files"]) return [TextContent( diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 5949fd68d2..6b7796048c 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -1,7 +1,7 @@ import pytest from pathlib import Path import git -from mcp_server_git.server import git_checkout, git_branch, git_add, git_status +from mcp_server_git.server import git_checkout, git_branch, git_add, git_status, git_commit_signed import shutil @pytest.fixture @@ -97,3 +97,42 @@ def test_git_status(test_repository): assert result is not None assert "On branch" in result or "branch" in result.lower() + +def test_git_commit_signed_without_key_id(test_repository): + # Create and stage a new file + file_path = Path(test_repository.working_dir) / "signed_test.txt" + file_path.write_text("testing signed commit") + test_repository.index.add(["signed_test.txt"]) + + # Note: This test may fail if GPG is not configured on the system + # In that case, it should raise a GitCommandError + try: + result = git_commit_signed(test_repository, "Test signed commit") + assert "Changes committed and signed successfully" in result + assert "with hash" in result + + # Verify the commit was actually created + latest_commit = test_repository.head.commit + assert latest_commit.message.strip() == "Test signed commit" + except git.GitCommandError as e: + # GPG not configured or signing failed - this is expected in CI/test environments + pytest.skip(f"GPG signing not available: {str(e)}") + +def test_git_commit_signed_with_key_id(test_repository): + # Create and stage a new file + file_path = Path(test_repository.working_dir) / "signed_test_with_key.txt" + file_path.write_text("testing signed commit with key") + test_repository.index.add(["signed_test_with_key.txt"]) + + # Note: This test may fail if GPG is not configured or key doesn't exist + try: + result = git_commit_signed(test_repository, "Test signed commit with key", "TESTKEY123") + assert "Changes committed and signed successfully" in result + assert "with hash" in result + + # Verify the commit was actually created + latest_commit = test_repository.head.commit + assert latest_commit.message.strip() == "Test signed commit with key" + except git.GitCommandError as e: + # GPG not configured, key not found, or signing failed - expected in CI/test environments + pytest.skip(f"GPG signing with specific key not available: {str(e)}") From 00306e3e2e8f4c3523f65d2ba7809a4eaf1a7415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20M=2E=20Martins?= Date: Tue, 11 Nov 2025 19:34:12 +1100 Subject: [PATCH 2/2] docs(git): add MCP usage instructions for git_commit_signed - Document how to call git_commit_signed via MCP and examples --- src/git/README.md | 39 ++++++++++++++++++---------- src/git/src/mcp_server_git/server.py | 15 +++++------ src/git/tests/test_server.py | 8 +++--- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index a922fdecc1..c0fe2e11ce 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -43,20 +43,29 @@ Please note that mcp-server-git is currently in early development. The functiona - `message` (string): Commit message - Returns: Confirmation with new commit hash -6. `git_add` +6. `git_commit_signed` + - Records changes to the repository and signs the commit with GPG + - Inputs: + - `repo_path` (string): Path to Git repository + - `message` (string): Commit message + - `key_id` (string, optional): GPG key ID to use for signing. If omitted, the default signing key configured in Git will be used. + - Behavior: Uses the Git CLI `-S`/`--gpg-sign` flag to create a GPG-signed commit. This passes through to the system's `git` and `gpg` configuration, so GPG must be available and configured on the host. + - Returns: Confirmation with new commit hash. If signing fails (for example, no GPG key is configured), the underlying Git command will raise an error. + +7. `git_add` - Adds file contents to the staging area - Inputs: - `repo_path` (string): Path to Git repository - `files` (string[]): Array of file paths to stage - Returns: Confirmation of staged files -7. `git_reset` +8. `git_reset` - Unstages all staged changes - Input: - `repo_path` (string): Path to Git repository - Returns: Confirmation of reset operation -8. `git_log` +9. `git_log` - Shows the commit logs with optional date filtering - Inputs: - `repo_path` (string): Path to Git repository @@ -65,34 +74,36 @@ Please note that mcp-server-git is currently in early development. The functiona - `end_timestamp` (string, optional): End timestamp for filtering commits. Accepts ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024') - Returns: Array of commit entries with hash, author, date, and message -9. `git_create_branch` +10. `git_create_branch` - Creates a new branch - Inputs: - `repo_path` (string): Path to Git repository - `branch_name` (string): Name of the new branch - `base_branch` (string, optional): Base branch to create from (defaults to current branch) - Returns: Confirmation of branch creation -10. `git_checkout` + +11. `git_checkout` - Switches branches - Inputs: - `repo_path` (string): Path to Git repository - `branch_name` (string): Name of branch to checkout - Returns: Confirmation of branch switch -11. `git_show` + +12. `git_show` - Shows the contents of a commit - Inputs: - `repo_path` (string): Path to Git repository - `revision` (string): The revision (commit hash, branch name, tag) to show - Returns: Contents of the specified commit -12. `git_branch` - - List Git branches - - Inputs: - - `repo_path` (string): Path to the Git repository. - - `branch_type` (string): Whether to list local branches ('local'), remote branches ('remote') or all branches('all'). - - `contains` (string, optional): The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified - - `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 +13. `git_branch` + - List Git branches + - Inputs: + - `repo_path` (string): Path to the Git repository. + - `branch_type` (string): Whether to list local branches ('local'), remote branches ('remote') or all branches('all'). + - `contains` (string, optional): The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified + - `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 ## Installation diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 2da718ff87..59eefcfb2c 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -137,7 +137,6 @@ def git_commit_signed(repo: git.Repo, message: str, key_id: str | None = None) - repo.git.commit("-S" + key_id, "-m", message) else: repo.git.commit("-S", "-m", message) - # Get the commit hash of HEAD commit_hash = repo.head.commit.hexsha return f"Changes committed and signed successfully with hash {commit_hash}" @@ -162,9 +161,9 @@ def git_log(repo: git.Repo, max_count: int = 10, start_timestamp: Optional[str] if end_timestamp: args.extend(['--until', end_timestamp]) args.extend(['--format=%H%n%an%n%ad%n%s%n']) - + log_output = repo.git.log(*args).split('\n') - + log = [] # Process commits in groups of 4 (hash, author, date, message) for i in range(0, len(log_output), 4): @@ -368,7 +367,7 @@ def by_commandline() -> Sequence[str]: @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: repo_path = Path(arguments["repo_path"]) - + # For all commands, we need an existing repo repo = git.Repo(repo_path) @@ -410,7 +409,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: case GitTools.COMMIT_SIGNED: result = git_commit_signed( - repo, + repo, arguments["message"], arguments.get("key_id") ) @@ -436,7 +435,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: # Update the LOG case: case GitTools.LOG: log = git_log( - repo, + repo, arguments.get("max_count", 10), arguments.get("start_timestamp"), arguments.get("end_timestamp") @@ -445,7 +444,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: type="text", text="Commit history:\n" + "\n".join(log) )] - + case GitTools.CREATE_BRANCH: result = git_create_branch( repo, @@ -482,7 +481,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: type="text", text=result )] - + case _: raise ValueError(f"Unknown tool: {name}") diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 6b7796048c..7151ff55f7 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -103,14 +103,14 @@ def test_git_commit_signed_without_key_id(test_repository): file_path = Path(test_repository.working_dir) / "signed_test.txt" file_path.write_text("testing signed commit") test_repository.index.add(["signed_test.txt"]) - + # Note: This test may fail if GPG is not configured on the system # In that case, it should raise a GitCommandError try: result = git_commit_signed(test_repository, "Test signed commit") assert "Changes committed and signed successfully" in result assert "with hash" in result - + # Verify the commit was actually created latest_commit = test_repository.head.commit assert latest_commit.message.strip() == "Test signed commit" @@ -123,13 +123,13 @@ def test_git_commit_signed_with_key_id(test_repository): file_path = Path(test_repository.working_dir) / "signed_test_with_key.txt" file_path.write_text("testing signed commit with key") test_repository.index.add(["signed_test_with_key.txt"]) - + # Note: This test may fail if GPG is not configured or key doesn't exist try: result = git_commit_signed(test_repository, "Test signed commit with key", "TESTKEY123") assert "Changes committed and signed successfully" in result assert "with hash" in result - + # Verify the commit was actually created latest_commit = test_repository.head.commit assert latest_commit.message.strip() == "Test signed commit with key"