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: "", 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: "", 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: " ", 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: " ", 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: "", 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 }