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/cmd/git-explorer/main.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

460 lines
9.6 KiB
Go

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
}