package git import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "github.com/xlgmokha/mcp/pkg/mcp" ) // Commit represents a git commit type Commit struct { Hash string Message string Author string Date string } // GitOperations provides git operations for a specific repository type GitOperations struct { repoPath string } // NewGitOperations creates a new GitOperations helper func NewGitOperations(repoPath string) *GitOperations { return &GitOperations{repoPath: repoPath} } // New creates a new Git MCP server func New(repoPath string) *mcp.Server { git := NewGitOperations(repoPath) builder := mcp.NewServerBuilder("mcp-git", "1.0.0") // Add git_status tool builder.AddTool(mcp.NewTool("git_status", "Shows the working tree status", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } output, err := git.runGitCommand(repoPath, "status") if err != nil { return mcp.NewToolError(fmt.Sprintf("git status failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Repository status:\n%s", output))), nil })) // Add git_diff_unstaged tool builder.AddTool(mcp.NewTool("git_diff_unstaged", "Shows changes in the working directory that are not yet staged", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } output, err := git.runGitCommand(repoPath, "diff") if err != nil { return mcp.NewToolError(fmt.Sprintf("git diff failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Unstaged changes:\n%s", output))), nil })) // Add git_diff_staged tool builder.AddTool(mcp.NewTool("git_diff_staged", "Shows changes that are staged for commit", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } output, err := git.runGitCommand(repoPath, "diff", "--cached") if err != nil { return mcp.NewToolError(fmt.Sprintf("git diff --cached failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Staged changes:\n%s", output))), nil })) // Add git_diff tool builder.AddTool(mcp.NewTool("git_diff", "Shows differences between branches or commits", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "target": map[string]interface{}{ "type": "string", "description": "Target branch or commit to diff against", }, }, "required": []string{"repo_path", "target"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } target, ok := req.Arguments["target"].(string) if !ok { return mcp.NewToolError("target is required"), nil } output, err := git.runGitCommand(repoPath, "diff", target) if err != nil { return mcp.NewToolError(fmt.Sprintf("git diff failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Diff with %s:\n%s", target, output))), nil })) // Add git_commit tool builder.AddTool(mcp.NewTool("git_commit", "Records changes to the repository", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "message": map[string]interface{}{ "type": "string", "description": "Commit message", }, }, "required": []string{"repo_path", "message"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } message, ok := req.Arguments["message"].(string) if !ok { return mcp.NewToolError("message is required"), nil } output, err := git.runGitCommand(repoPath, "commit", "-m", message) if err != nil { return mcp.NewToolError(fmt.Sprintf("git commit failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Changes committed successfully:\n%s", output))), nil })) // Add git_add tool builder.AddTool(mcp.NewTool("git_add", "Adds file contents to the staging area", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "files": map[string]interface{}{ "type": "array", "items": map[string]interface{}{ "type": "string", }, "description": "List of files to add", }, }, "required": []string{"repo_path", "files"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } filesInterface, ok := req.Arguments["files"] if !ok { return mcp.NewToolError("files is required"), nil } files, err := convertToStringSlice(filesInterface) if err != nil { return mcp.NewToolError("files must be an array of strings"), nil } args := append([]string{"add"}, files...) _, err = git.runGitCommand(repoPath, args...) if err != nil { return mcp.NewToolError(fmt.Sprintf("git add failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent("Files staged successfully")), nil })) // Add git_reset tool builder.AddTool(mcp.NewTool("git_reset", "Unstages all staged changes", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } _, err := git.runGitCommand(repoPath, "reset") if err != nil { return mcp.NewToolError(fmt.Sprintf("git reset failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent("All staged changes reset")), nil })) // Add git_log tool builder.AddTool(mcp.NewTool("git_log", "Shows the commit logs", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "max_count": map[string]interface{}{ "type": "integer", "description": "Maximum number of commits to show", "default": 10, }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } maxCount := 10 if mc, exists := req.Arguments["max_count"]; exists { if count, ok := mc.(float64); ok { maxCount = int(count) } } output, err := git.runGitCommand(repoPath, "log", "--oneline", "-n", strconv.Itoa(maxCount)) if err != nil { return mcp.NewToolError(fmt.Sprintf("git log failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Commit history:\n%s", output))), nil })) // Add git_create_branch tool builder.AddTool(mcp.NewTool("git_create_branch", "Creates a new branch from an optional base branch", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "branch_name": map[string]interface{}{ "type": "string", "description": "Name of the new branch", }, "base_branch": map[string]interface{}{ "type": "string", "description": "Base branch to create from (optional)", }, }, "required": []string{"repo_path", "branch_name"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } branchName, ok := req.Arguments["branch_name"].(string) if !ok { return mcp.NewToolError("branch_name is required"), nil } baseBranch, _ := req.Arguments["base_branch"].(string) var args []string if baseBranch != "" { args = []string{"checkout", "-b", branchName, baseBranch} } else { args = []string{"checkout", "-b", branchName} } _, err := git.runGitCommand(repoPath, args...) if err != nil { return mcp.NewToolError(fmt.Sprintf("git create branch failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Created branch '%s'", branchName))), nil })) // Add git_checkout tool builder.AddTool(mcp.NewTool("git_checkout", "Switches branches", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "branch_name": map[string]interface{}{ "type": "string", "description": "Name of the branch to checkout", }, }, "required": []string{"repo_path", "branch_name"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } branchName, ok := req.Arguments["branch_name"].(string) if !ok { return mcp.NewToolError("branch_name is required"), nil } _, err := git.runGitCommand(repoPath, "checkout", branchName) if err != nil { return mcp.NewToolError(fmt.Sprintf("git checkout failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Switched to branch '%s'", branchName))), nil })) // Add git_show tool builder.AddTool(mcp.NewTool("git_show", "Shows the contents of a commit", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path to the Git repository", }, "revision": map[string]interface{}{ "type": "string", "description": "Commit hash or reference to show", }, }, "required": []string{"repo_path", "revision"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } revision, ok := req.Arguments["revision"].(string) if !ok { return mcp.NewToolError("revision is required"), nil } output, err := git.runGitCommand(repoPath, "show", revision) if err != nil { return mcp.NewToolError(fmt.Sprintf("git show failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(output)), nil })) // Add git_init tool builder.AddTool(mcp.NewTool("git_init", "Initialize a new Git repository", map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "repo_path": map[string]interface{}{ "type": "string", "description": "Path where to initialize the Git repository", }, }, "required": []string{"repo_path"}, }, func(req mcp.CallToolRequest) (mcp.CallToolResult, error) { repoPath, ok := req.Arguments["repo_path"].(string) if !ok { repoPath = git.repoPath } if err := os.MkdirAll(repoPath, 0755); err != nil { return mcp.NewToolError(fmt.Sprintf("failed to create directory: %v", err)), nil } _, err := git.runGitCommand(repoPath, "init") if err != nil { return mcp.NewToolError(fmt.Sprintf("git init failed: %v", err)), nil } return mcp.NewToolResult(mcp.NewTextContent(fmt.Sprintf("Initialized empty Git repository in %s", repoPath))), nil })) // Add commit-message prompt builder.AddPrompt(mcp.NewPrompt("commit-message", "Prompt for crafting a well-structured git commit message", []mcp.PromptArgument{ { Name: "changes", Description: "Description of the changes being committed", Required: true, }, { Name: "type", Description: "Type of change (feat, fix, docs, style, refactor, test, chore)", Required: false, }, { Name: "breaking", Description: "Whether this is a breaking change (true/false)", Required: false, }, }, func(req mcp.GetPromptRequest) (mcp.GetPromptResult, error) { changes, hasChanges := req.Arguments["changes"].(string) commitType, hasType := req.Arguments["type"].(string) breaking, hasBreaking := req.Arguments["breaking"] if !hasChanges || changes == "" { return mcp.GetPromptResult{}, fmt.Errorf("changes argument is required") } if !hasType || commitType == "" { commitType = "feat" } isBreaking := false if hasBreaking { if breakingBool, ok := breaking.(bool); ok { isBreaking = breakingBool } else if breakingStr, ok := breaking.(string); ok { isBreaking = breakingStr == "true" } } var messages []mcp.PromptMessage userContent := fmt.Sprintf(`I need help writing a git commit message for the following changes: %s Please help me craft a well-structured commit message following conventional commit format.`, changes) if hasType { userContent += fmt.Sprintf("\n\nCommit type: %s", commitType) } if isBreaking { userContent += "\n\nThis is a BREAKING CHANGE." } messages = append(messages, mcp.PromptMessage{ Role: "user", Content: mcp.NewTextContent(userContent), }) breakingPrefix := "" if isBreaking { breakingPrefix = "!" } assistantContent := fmt.Sprintf(`I'll help you create a conventional commit message. Here's the suggested format: **Commit message:** %s%s: %s **Format explanation:** - Type: %s (indicates the nature of the change) - Description: Clear, concise summary in present tense %s **Additional guidelines:** - Keep the subject line under 50 characters - Use imperative mood ("add" not "added") - Don't end subject line with a period - Include body if needed to explain what and why`, commitType, breakingPrefix, changes, commitType, func() string { if isBreaking { return "- Breaking change: This change breaks backward compatibility" } return "" }()) messages = append(messages, mcp.PromptMessage{ Role: "assistant", Content: mcp.NewTextContent(assistantContent), }) description := fmt.Sprintf("Commit message guidance for %s changes", commitType) if isBreaking { description += " (BREAKING)" } return mcp.GetPromptResult{ Description: description, Messages: messages, }, nil })) // Add git:// pattern resource for dynamic file access builder.AddResource(mcp.NewResource( "git://", "Git Repository", "", func(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) { return git.handleGitResource(req) }, )) // Add repository root if it's a git repository gitDir := filepath.Join(git.repoPath, ".git") if _, err := os.Stat(gitDir); err == nil { currentBranch, err := git.getCurrentBranch() if err != nil { currentBranch = "unknown" } gitURI := "git://" + git.repoPath repoName := filepath.Base(git.repoPath) if repoName == "." || repoName == "/" { repoName = git.repoPath } rootName := fmt.Sprintf("Git Repository: %s (branch: %s)", repoName, currentBranch) builder.AddRoot(mcp.NewRoot(gitURI, rootName)) } return builder.Build() } // Helper methods for GitOperations func (git *GitOperations) runGitCommand(repoPath string, args ...string) (string, error) { if _, err := os.Stat(repoPath); os.IsNotExist(err) { return "", fmt.Errorf("repository path does not exist: %s", repoPath) } if len(args) > 0 && args[0] != "init" { gitDir := filepath.Join(repoPath, ".git") if _, err := os.Stat(gitDir); os.IsNotExist(err) { return "", fmt.Errorf("not a git repository: %s", repoPath) } } cmd := exec.Command("git", args...) cmd.Dir = repoPath output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("%v: %s", err, string(output)) } return strings.TrimSpace(string(output)), nil } func (git *GitOperations) getCurrentBranch() (string, error) { output, err := git.runGitCommand(git.repoPath, "branch", "--show-current") if err != nil { return "", err } branch := strings.TrimSpace(output) if branch == "" { branch = "HEAD" } return branch, nil } func (git *GitOperations) getTrackedFiles() ([]string, error) { output, err := git.runGitCommand(git.repoPath, "ls-files") if err != nil { return nil, err } if output == "" { return []string{}, nil } files := strings.Split(output, "\n") var filteredFiles []string for _, file := range files { file = strings.TrimSpace(file) if file != "" && !strings.HasPrefix(file, ".") { filteredFiles = append(filteredFiles, file) } } return filteredFiles, nil } func (git *GitOperations) getBranches() ([]string, error) { output, err := git.runGitCommand(git.repoPath, "branch", "--format=%(refname:short)") if err != nil { return nil, err } if output == "" { return []string{}, nil } branches := strings.Split(output, "\n") var filteredBranches []string for _, branch := range branches { branch = strings.TrimSpace(branch) if branch != "" { filteredBranches = append(filteredBranches, branch) } } return filteredBranches, nil } func (git *GitOperations) getRecentCommits(count int) ([]Commit, error) { output, err := git.runGitCommand(git.repoPath, "log", "--format=%H|%s|%an|%ad", "--date=short", "-n", strconv.Itoa(count)) if err != nil { return nil, err } if output == "" { return []Commit{}, nil } lines := strings.Split(output, "\n") var commits []Commit for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, "|") if len(parts) >= 4 { commits = append(commits, Commit{ Hash: parts[0], Message: parts[1], Author: parts[2], Date: parts[3], }) } } return commits, nil } func (git *GitOperations) handleGitResource(req mcp.ReadResourceRequest) (mcp.ReadResourceResult, error) { if !strings.HasPrefix(req.URI, "git://") { return mcp.ReadResourceResult{}, fmt.Errorf("invalid git URI: %s", req.URI) } uriPath := req.URI[6:] parts := strings.Split(uriPath, "/") if len(parts) < 3 { return mcp.ReadResourceResult{}, fmt.Errorf("invalid git URI format: %s", req.URI) } repoPath := parts[0] resourceType := parts[1] resourcePath := strings.Join(parts[2:], "/") if repoPath != git.repoPath { return mcp.ReadResourceResult{}, fmt.Errorf("access denied: repository path mismatch") } switch resourceType { case "branch": return git.handleBranchResource(resourcePath) case "commit": return git.handleCommitResource(resourcePath) default: return git.handleFileResource(resourceType, resourcePath) } } func (git *GitOperations) handleFileResource(branch, filePath string) (mcp.ReadResourceResult, error) { gitPath := fmt.Sprintf("%s:%s", branch, filePath) output, err := git.runGitCommand(git.repoPath, "show", gitPath) if err != nil { return mcp.ReadResourceResult{}, fmt.Errorf("failed to read git file: %v", err) } return mcp.ReadResourceResult{ Contents: []mcp.Content{ mcp.NewTextContent(output), }, }, nil } func (git *GitOperations) handleBranchResource(branchName string) (mcp.ReadResourceResult, error) { output, err := git.runGitCommand(git.repoPath, "log", "--oneline", "-n", "5", branchName) if err != nil { return mcp.ReadResourceResult{}, fmt.Errorf("failed to get branch info: %v", err) } content := fmt.Sprintf("Branch: %s\n\nRecent commits:\n%s", branchName, output) return mcp.ReadResourceResult{ Contents: []mcp.Content{ mcp.NewTextContent(content), }, }, nil } func (git *GitOperations) handleCommitResource(commitHash string) (mcp.ReadResourceResult, error) { output, err := git.runGitCommand(git.repoPath, "show", "--stat", commitHash) if err != nil { return mcp.ReadResourceResult{}, fmt.Errorf("failed to get commit info: %v", err) } return mcp.ReadResourceResult{ Contents: []mcp.Content{ mcp.NewTextContent(output), }, }, nil } // Helper function to convert interface{} to []string func convertToStringSlice(input interface{}) ([]string, error) { switch v := input.(type) { case []interface{}: result := make([]string, len(v)) for i, item := range v { str, ok := item.(string) if !ok { return nil, fmt.Errorf("item at index %d is not a string", i) } result[i] = str } return result, nil case []string: return v, nil default: return nil, fmt.Errorf("input is not a slice") } } // Helper function to determine MIME type for git files func getGitMimeType(filePath string) string { ext := strings.ToLower(filepath.Ext(filePath)) switch ext { case ".go": return "text/x-go" case ".js": return "text/javascript" case ".ts": return "text/typescript" case ".py": return "text/x-python" case ".md": return "text/markdown" case ".json": return "application/json" case ".yaml", ".yml": return "application/x-yaml" case ".xml": return "application/xml" case ".html", ".htm": return "text/html" case ".css": return "text/css" case ".txt": return "text/plain" default: return "text/plain" } }