From 1d6eeea334fb625c6e9260ef4009ef18c8bcd390 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 | 42 +++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 1968ded2f5..b5616d1ec7 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(".") @@ -277,6 +297,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", @@ -388,6 +413,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 a7b4f88613..cc38fe2138 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -13,7 +13,8 @@ git_reset, git_log, git_create_branch, - git_show + git_show, + git_commit_signed, ) import shutil @@ -246,3 +247,42 @@ def test_git_show_initial_commit(test_repository): assert "Commit:" in result assert "initial commit" in result assert "test.txt" in result + +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 48edde6813a307e093b8ec50ac3b17911d2fa00f 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 | 3 +-- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index cdc77daa1b..6646ccdd19 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -45,20 +45,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 @@ -67,34 +76,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 b5616d1ec7..1dd4e6ab48 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}" @@ -415,7 +414,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") )