This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/gitexplorer/handlers.go
Torjus Håkestad 75673974a2 feat: add git-explorer MCP server for read-only repository access
Implements a new MCP server that provides read-only access to git
repositories using go-git. Designed for deployment verification by
comparing deployed flake revisions against source repositories.

9 tools: resolve_ref, get_log, get_commit_info, get_diff_files,
get_file_at_commit, is_ancestor, commits_between, list_branches,
search_commits.

Includes CLI commands, NixOS module, and comprehensive tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 04:26:38 +01:00

441 lines
12 KiB
Go

package gitexplorer
import (
"context"
"fmt"
"git.t-juice.club/torjus/labmcp/internal/mcp"
)
// RegisterHandlers registers all git-explorer tool handlers on the MCP server.
func RegisterHandlers(server *mcp.Server, client *GitClient) {
server.RegisterTool(resolveRefTool(), makeResolveRefHandler(client))
server.RegisterTool(getLogTool(), makeGetLogHandler(client))
server.RegisterTool(getCommitInfoTool(), makeGetCommitInfoHandler(client))
server.RegisterTool(getDiffFilesTool(), makeGetDiffFilesHandler(client))
server.RegisterTool(getFileAtCommitTool(), makeGetFileAtCommitHandler(client))
server.RegisterTool(isAncestorTool(), makeIsAncestorHandler(client))
server.RegisterTool(commitsBetweenTool(), makeCommitsBetweenHandler(client))
server.RegisterTool(listBranchesTool(), makeListBranchesHandler(client))
server.RegisterTool(searchCommitsTool(), makeSearchCommitsHandler(client))
}
// Tool definitions
func resolveRefTool() mcp.Tool {
return mcp.Tool{
Name: "resolve_ref",
Description: "Resolve a git ref (branch, tag, or commit hash) to its full commit hash",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Git ref to resolve (e.g., 'main', 'v1.0.0', 'HEAD', commit hash)",
},
},
Required: []string{"ref"},
},
}
}
func getLogTool() mcp.Tool {
return mcp.Tool{
Name: "get_log",
Description: "Get commit log starting from a ref, with optional filters",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Starting ref for the log (default: HEAD)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of commits to return (default: 20, max: %d)", Limits.MaxLogEntries),
Default: 20,
},
"author": {
Type: "string",
Description: "Filter by author name or email (substring match)",
},
"since": {
Type: "string",
Description: "Stop log at this ref (exclusive)",
},
"path": {
Type: "string",
Description: "Filter commits that affect this path",
},
},
},
}
}
func getCommitInfoTool() mcp.Tool {
return mcp.Tool{
Name: "get_commit_info",
Description: "Get full details for a specific commit including message, author, and optionally file statistics",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Commit ref (hash, branch, tag, or HEAD)",
},
"include_stats": {
Type: "boolean",
Description: "Include file change statistics (default: true)",
Default: true,
},
},
Required: []string{"ref"},
},
}
}
func getDiffFilesTool() mcp.Tool {
return mcp.Tool{
Name: "get_diff_files",
Description: "Get list of files changed between two commits with change type and line counts",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"from_ref": {
Type: "string",
Description: "Starting commit ref (the older commit)",
},
"to_ref": {
Type: "string",
Description: "Ending commit ref (the newer commit)",
},
},
Required: []string{"from_ref", "to_ref"},
},
}
}
func getFileAtCommitTool() mcp.Tool {
return mcp.Tool{
Name: "get_file_at_commit",
Description: fmt.Sprintf("Get the contents of a file at a specific commit (max %dKB)", Limits.MaxFileContent/1024),
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Commit ref (hash, branch, tag, or HEAD)",
},
"path": {
Type: "string",
Description: "Path to the file relative to repository root",
},
},
Required: []string{"ref", "path"},
},
}
}
func isAncestorTool() mcp.Tool {
return mcp.Tool{
Name: "is_ancestor",
Description: "Check if one commit is an ancestor of another",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ancestor": {
Type: "string",
Description: "Potential ancestor commit ref",
},
"descendant": {
Type: "string",
Description: "Potential descendant commit ref",
},
},
Required: []string{"ancestor", "descendant"},
},
}
}
func commitsBetweenTool() mcp.Tool {
return mcp.Tool{
Name: "commits_between",
Description: "Get all commits between two refs (from is exclusive, to is inclusive)",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"from_ref": {
Type: "string",
Description: "Starting commit ref (exclusive - commits after this)",
},
"to_ref": {
Type: "string",
Description: "Ending commit ref (inclusive - up to and including this)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of commits (default: %d)", Limits.MaxLogEntries),
Default: Limits.MaxLogEntries,
},
},
Required: []string{"from_ref", "to_ref"},
},
}
}
func listBranchesTool() mcp.Tool {
return mcp.Tool{
Name: "list_branches",
Description: "List all branches in the repository",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"include_remote": {
Type: "boolean",
Description: "Include remote-tracking branches (default: false)",
Default: false,
},
},
},
}
}
func searchCommitsTool() mcp.Tool {
return mcp.Tool{
Name: "search_commits",
Description: "Search commit messages for a pattern (case-insensitive)",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"query": {
Type: "string",
Description: "Search pattern to match in commit messages",
},
"ref": {
Type: "string",
Description: "Starting ref for the search (default: HEAD)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of results (default: 20, max: %d)", Limits.MaxSearchResult),
Default: 20,
},
},
Required: []string{"query"},
},
}
}
// Handler constructors
func makeResolveRefHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
result, err := client.ResolveRef(ref)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatResolveResult(result))},
}, nil
}
}
func makeGetLogHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref := "HEAD"
if r, ok := args["ref"].(string); ok && r != "" {
ref = r
}
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
author, _ := args["author"].(string)
since, _ := args["since"].(string)
path, _ := args["path"].(string)
entries, err := client.GetLog(ref, limit, author, since, path)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatLogEntries(entries))},
}, nil
}
}
func makeGetCommitInfoHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
includeStats := true
if s, ok := args["include_stats"].(bool); ok {
includeStats = s
}
info, err := client.GetCommitInfo(ref, includeStats)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatCommitInfo(info))},
}, nil
}
}
func makeGetDiffFilesHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
fromRef, _ := args["from_ref"].(string)
if fromRef == "" {
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
}
toRef, _ := args["to_ref"].(string)
if toRef == "" {
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
}
result, err := client.GetDiffFiles(fromRef, toRef)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatDiffResult(result))},
}, nil
}
}
func makeGetFileAtCommitHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
path, _ := args["path"].(string)
if path == "" {
return mcp.ErrorContent(fmt.Errorf("path is required")), nil
}
content, err := client.GetFileAtCommit(ref, path)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatFileContent(content))},
}, nil
}
}
func makeIsAncestorHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ancestor, _ := args["ancestor"].(string)
if ancestor == "" {
return mcp.ErrorContent(fmt.Errorf("ancestor is required")), nil
}
descendant, _ := args["descendant"].(string)
if descendant == "" {
return mcp.ErrorContent(fmt.Errorf("descendant is required")), nil
}
result, err := client.IsAncestor(ancestor, descendant)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatAncestryResult(result))},
}, nil
}
}
func makeCommitsBetweenHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
fromRef, _ := args["from_ref"].(string)
if fromRef == "" {
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
}
toRef, _ := args["to_ref"].(string)
if toRef == "" {
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
}
limit := Limits.MaxLogEntries
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
result, err := client.CommitsBetween(fromRef, toRef, limit)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatCommitRange(result))},
}, nil
}
}
func makeListBranchesHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
includeRemote := false
if r, ok := args["include_remote"].(bool); ok {
includeRemote = r
}
result, err := client.ListBranches(includeRemote)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatBranchList(result))},
}, nil
}
}
func makeSearchCommitsHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
query, _ := args["query"].(string)
if query == "" {
return mcp.ErrorContent(fmt.Errorf("query is required")), nil
}
ref := "HEAD"
if r, ok := args["ref"].(string); ok && r != "" {
ref = r
}
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
result, err := client.SearchCommits(ref, query, limit)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatSearchResult(result))},
}, nil
}
}