Update Go module path and all import references for Gitea to Forgejo host migration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
460 lines
9.6 KiB
Go
460 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
"code.t-juice.club/torjus/labmcp/internal/gitexplorer"
|
|
"code.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
|
|
}
|