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>
This commit is contained in:
459
cmd/git-explorer/main.go
Normal file
459
cmd/git-explorer/main.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/gitexplorer"
|
||||
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "git-explorer",
|
||||
Usage: "Read-only MCP server for git repository exploration",
|
||||
Version: version,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "repo",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Path to git repository",
|
||||
EnvVars: []string{"GIT_REPO_PATH"},
|
||||
Value: ".",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "default-remote",
|
||||
Usage: "Default remote name",
|
||||
EnvVars: []string{"GIT_DEFAULT_REMOTE"},
|
||||
Value: "origin",
|
||||
},
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
serveCommand(),
|
||||
resolveCommand(),
|
||||
logCommand(),
|
||||
showCommand(),
|
||||
diffCommand(),
|
||||
catCommand(),
|
||||
branchesCommand(),
|
||||
searchCommand(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func serveCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "serve",
|
||||
Usage: "Run MCP server for git exploration",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "transport",
|
||||
Aliases: []string{"t"},
|
||||
Usage: "Transport type: 'stdio' or 'http'",
|
||||
Value: "stdio",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "HTTP listen address",
|
||||
Value: "127.0.0.1:8085",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-endpoint",
|
||||
Usage: "HTTP endpoint path",
|
||||
Value: "/mcp",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "allowed-origins",
|
||||
Usage: "Allowed Origin headers for CORS",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tls-cert",
|
||||
Usage: "TLS certificate file",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tls-key",
|
||||
Usage: "TLS key file",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "session-ttl",
|
||||
Usage: "Session TTL for HTTP transport",
|
||||
Value: 30 * time.Minute,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runServe(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "resolve",
|
||||
Usage: "Resolve a ref to a commit hash",
|
||||
ArgsUsage: "<ref>",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return fmt.Errorf("ref argument required")
|
||||
}
|
||||
return runResolve(c, c.Args().First())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func logCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "log",
|
||||
Usage: "Show commit log",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "ref",
|
||||
Usage: "Starting ref (default: HEAD)",
|
||||
Value: "HEAD",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Maximum number of commits",
|
||||
Value: 10,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "author",
|
||||
Usage: "Filter by author",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "path",
|
||||
Usage: "Filter by path",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runLog(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func showCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "show",
|
||||
Usage: "Show commit details",
|
||||
ArgsUsage: "<ref>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "stats",
|
||||
Usage: "Include file statistics",
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
ref := "HEAD"
|
||||
if c.NArg() > 0 {
|
||||
ref = c.Args().First()
|
||||
}
|
||||
return runShow(c, ref)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func diffCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "diff",
|
||||
Usage: "Show files changed between two commits",
|
||||
ArgsUsage: "<from-ref> <to-ref>",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 2 {
|
||||
return fmt.Errorf("both from-ref and to-ref arguments required")
|
||||
}
|
||||
return runDiff(c, c.Args().Get(0), c.Args().Get(1))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func catCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cat",
|
||||
Usage: "Show file contents at a commit",
|
||||
ArgsUsage: "<ref> <path>",
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 2 {
|
||||
return fmt.Errorf("both ref and path arguments required")
|
||||
}
|
||||
return runCat(c, c.Args().Get(0), c.Args().Get(1))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func branchesCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "branches",
|
||||
Usage: "List branches",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "remote",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Include remote branches",
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runBranches(c)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func searchCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "search",
|
||||
Usage: "Search commit messages",
|
||||
ArgsUsage: "<query>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "ref",
|
||||
Usage: "Starting ref (default: HEAD)",
|
||||
Value: "HEAD",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Maximum number of results",
|
||||
Value: 20,
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
if c.NArg() < 1 {
|
||||
return fmt.Errorf("query argument required")
|
||||
}
|
||||
return runSearch(c, c.Args().First())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runServe(c *cli.Context) error {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
repoPath := c.String("repo")
|
||||
client, err := gitexplorer.NewGitClient(repoPath, c.String("default-remote"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open repository: %w", err)
|
||||
}
|
||||
|
||||
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
|
||||
config := mcp.DefaultGitExplorerConfig()
|
||||
|
||||
server := mcp.NewGenericServer(logger, config)
|
||||
gitexplorer.RegisterHandlers(server, client)
|
||||
|
||||
transport := c.String("transport")
|
||||
switch transport {
|
||||
case "stdio":
|
||||
logger.Printf("Starting git-explorer MCP server on stdio (repo: %s)...", repoPath)
|
||||
return server.Run(ctx, os.Stdin, os.Stdout)
|
||||
|
||||
case "http":
|
||||
httpConfig := mcp.HTTPConfig{
|
||||
Address: c.String("http-address"),
|
||||
Endpoint: c.String("http-endpoint"),
|
||||
AllowedOrigins: c.StringSlice("allowed-origins"),
|
||||
SessionTTL: c.Duration("session-ttl"),
|
||||
TLSCertFile: c.String("tls-cert"),
|
||||
TLSKeyFile: c.String("tls-key"),
|
||||
}
|
||||
httpTransport := mcp.NewHTTPTransport(server, httpConfig)
|
||||
return httpTransport.Run(ctx)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport)
|
||||
}
|
||||
}
|
||||
|
||||
func getClient(c *cli.Context) (*gitexplorer.GitClient, error) {
|
||||
return gitexplorer.NewGitClient(c.String("repo"), c.String("default-remote"))
|
||||
}
|
||||
|
||||
func runResolve(c *cli.Context, ref string) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.ResolveRef(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s (%s) -> %s\n", result.Ref, result.Type, result.Commit)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLog(c *cli.Context) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := client.GetLog(
|
||||
c.String("ref"),
|
||||
c.Int("limit"),
|
||||
c.String("author"),
|
||||
"",
|
||||
c.String("path"),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No commits found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
fmt.Printf("%s %s\n", e.ShortHash, e.Subject)
|
||||
fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email)
|
||||
fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runShow(c *cli.Context, ref string) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := client.GetCommitInfo(ref, c.Bool("stats"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("commit %s\n", info.Hash)
|
||||
fmt.Printf("Author: %s <%s>\n", info.Author, info.Email)
|
||||
fmt.Printf("Date: %s\n", info.Date.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if len(info.Parents) > 0 {
|
||||
fmt.Printf("Parents: %v\n", info.Parents)
|
||||
}
|
||||
|
||||
if info.Stats != nil {
|
||||
fmt.Printf("\n%d file(s) changed, %d insertions(+), %d deletions(-)\n",
|
||||
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions)
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s", info.Message)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDiff(c *cli.Context, fromRef, toRef string) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.GetDiffFiles(fromRef, toRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(result.Files) == 0 {
|
||||
fmt.Println("No files changed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Comparing %s..%s\n\n", result.FromCommit[:7], result.ToCommit[:7])
|
||||
|
||||
for _, f := range result.Files {
|
||||
status := f.Status[0:1] // First letter: A, M, D, R
|
||||
path := f.Path
|
||||
if f.OldPath != "" {
|
||||
path = fmt.Sprintf("%s -> %s", f.OldPath, f.Path)
|
||||
}
|
||||
fmt.Printf("%s %s (+%d -%d)\n", status, path, f.Additions, f.Deletions)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCat(c *cli.Context, ref, path string) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := client.GetFileAtCommit(ref, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Print(content.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runBranches(c *cli.Context) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.ListBranches(c.Bool("remote"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.Total == 0 {
|
||||
fmt.Println("No branches found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, b := range result.Branches {
|
||||
marker := " "
|
||||
if b.IsHead {
|
||||
marker = "*"
|
||||
}
|
||||
remoteMarker := ""
|
||||
if b.IsRemote {
|
||||
remoteMarker = " (remote)"
|
||||
}
|
||||
fmt.Printf("%s %s -> %s%s\n", marker, b.Name, b.Commit[:7], remoteMarker)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSearch(c *cli.Context, query string) error {
|
||||
client, err := getClient(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := client.SearchCommits(c.String("ref"), query, c.Int("limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.Count == 0 {
|
||||
fmt.Printf("No commits matching '%s'.\n", query)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d commit(s) matching '%s':\n\n", result.Count, query)
|
||||
for _, e := range result.Commits {
|
||||
fmt.Printf("%s %s\n", e.ShortHash, e.Subject)
|
||||
fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email)
|
||||
fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user