package main import ( "context" "fmt" "log" "os" "os/signal" "strings" "syscall" "time" "github.com/urfave/cli/v2" "git.t-juice.club/torjus/labmcp/internal/database" "git.t-juice.club/torjus/labmcp/internal/mcp" "git.t-juice.club/torjus/labmcp/internal/nixos" ) const ( defaultDatabase = "sqlite://nixos-options.db" version = "0.2.0" ) func main() { app := &cli.App{ Name: "nixos-options", Usage: "MCP server for NixOS options search and query", Version: version, Flags: []cli.Flag{ &cli.StringFlag{ Name: "database", Aliases: []string{"d"}, Usage: "Database connection string (postgres://... or sqlite://...)", EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, Value: defaultDatabase, }, }, Commands: []*cli.Command{ { Name: "serve", Usage: "Run MCP server", 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:8080", }, &cli.StringFlag{ Name: "http-endpoint", Usage: "HTTP endpoint path", Value: "/mcp", }, &cli.StringSliceFlag{ Name: "allowed-origins", Usage: "Allowed Origin headers for CORS (can be specified multiple times)", }, &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) }, }, { Name: "index", Usage: "Index a nixpkgs revision", ArgsUsage: "", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "no-files", Usage: "Skip indexing file contents (faster, disables get_file tool)", }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "Force re-indexing even if revision already exists", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("revision argument required") } return runIndex(c, c.Args().First(), !c.Bool("no-files"), c.Bool("force")) }, }, { Name: "list", Usage: "List indexed revisions", Action: func(c *cli.Context) error { return runList(c) }, }, { Name: "search", Usage: "Search for options", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, &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()) }, }, { Name: "get", Usage: "Get details for a specific option", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{ Name: "revision", Aliases: []string{"r"}, Usage: "Revision to search (default: most recent)", }, }, Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("option name required") } return runGet(c, c.Args().First()) }, }, { Name: "delete", Usage: "Delete an indexed revision", ArgsUsage: "", Action: func(c *cli.Context) error { if c.NArg() < 1 { return fmt.Errorf("revision argument required") } return runDelete(c, c.Args().First()) }, }, }, } if err := app.Run(os.Args); err != nil { log.Fatal(err) } } // openStore opens a database store based on the connection string. func openStore(connStr string) (database.Store, error) { if strings.HasPrefix(connStr, "sqlite://") { path := strings.TrimPrefix(connStr, "sqlite://") return database.NewSQLiteStore(path) } if strings.HasPrefix(connStr, "postgres://") || strings.HasPrefix(connStr, "postgresql://") { return database.NewPostgresStore(connStr) } // Default to SQLite with the connection string as path return database.NewSQLiteStore(connStr) } func runServe(c *cli.Context) error { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) config := mcp.DefaultNixOSConfig() server := mcp.NewServer(store, logger, config) indexer := nixos.NewIndexer(store) server.RegisterHandlers(indexer) transport := c.String("transport") switch transport { case "stdio": logger.Println("Starting MCP server on stdio...") return server.Run(ctx, os.Stdin, os.Stdout) case "http": config := 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, config) return httpTransport.Run(ctx) default: return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport) } } func runIndex(c *cli.Context, revision string, indexFiles bool, force bool) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } indexer := nixos.NewIndexer(store) fmt.Printf("Indexing revision: %s\n", revision) var result *nixos.IndexResult if force { result, err = indexer.ReindexRevision(ctx, revision) } else { result, err = indexer.IndexRevision(ctx, revision) } if err != nil { return fmt.Errorf("indexing failed: %w", err) } if result.AlreadyIndexed { fmt.Printf("Revision already indexed (%d options). Use --force to re-index.\n", result.OptionCount) return nil } fmt.Printf("Indexed %d options in %s\n", result.OptionCount, result.Duration) fmt.Printf("Git hash: %s\n", result.Revision.GitHash) if result.Revision.ChannelName != "" { fmt.Printf("Channel: %s\n", result.Revision.ChannelName) } if indexFiles { fmt.Println("Indexing files...") fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, result.Revision.GitHash) if err != nil { return fmt.Errorf("file indexing failed: %w", err) } fmt.Printf("Indexed %d files\n", fileCount) } return nil } func runList(c *cli.Context) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } revisions, err := store.ListRevisions(ctx) if err != nil { return fmt.Errorf("failed to list revisions: %w", err) } if len(revisions) == 0 { fmt.Println("No revisions indexed.") fmt.Println("Use 'nixos-options index ' to index a nixpkgs version.") return nil } fmt.Printf("Indexed revisions (%d):\n\n", len(revisions)) for _, rev := range revisions { fmt.Printf(" %s", rev.GitHash[:12]) if rev.ChannelName != "" { fmt.Printf(" (%s)", rev.ChannelName) } fmt.Printf("\n Options: %d, Indexed: %s\n", rev.OptionCount, rev.IndexedAt.Format("2006-01-02 15:04")) } return nil } func runSearch(c *cli.Context, query string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } // Find revision var rev *database.Revision revisionArg := c.String("revision") if revisionArg != "" { rev, err = store.GetRevision(ctx, revisionArg) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } if rev == nil { rev, err = store.GetRevisionByChannel(ctx, revisionArg) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } } } else { revisions, err := store.ListRevisions(ctx) if err != nil { return fmt.Errorf("failed to list revisions: %w", err) } if len(revisions) > 0 { rev = revisions[0] } } if rev == nil { return fmt.Errorf("no indexed revision found") } filters := database.SearchFilters{ Limit: c.Int("limit"), } options, err := store.SearchOptions(ctx, rev.ID, query, filters) if err != nil { return fmt.Errorf("search failed: %w", err) } if len(options) == 0 { fmt.Printf("No options found matching '%s'\n", query) return nil } fmt.Printf("Found %d options matching '%s':\n\n", len(options), query) for _, opt := range options { fmt.Printf(" %s\n", opt.Name) fmt.Printf(" Type: %s\n", opt.Type) if opt.Description != "" { desc := opt.Description if len(desc) > 100 { desc = desc[:100] + "..." } fmt.Printf(" %s\n", desc) } fmt.Println() } return nil } func runGet(c *cli.Context, name string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } // Find revision var rev *database.Revision revisionArg := c.String("revision") if revisionArg != "" { rev, err = store.GetRevision(ctx, revisionArg) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } if rev == nil { rev, err = store.GetRevisionByChannel(ctx, revisionArg) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } } } else { revisions, err := store.ListRevisions(ctx) if err != nil { return fmt.Errorf("failed to list revisions: %w", err) } if len(revisions) > 0 { rev = revisions[0] } } if rev == nil { return fmt.Errorf("no indexed revision found") } opt, err := store.GetOption(ctx, rev.ID, name) if err != nil { return fmt.Errorf("failed to get option: %w", err) } if opt == nil { return fmt.Errorf("option '%s' not found", name) } fmt.Printf("%s\n", opt.Name) fmt.Printf(" Type: %s\n", opt.Type) if opt.Description != "" { fmt.Printf(" Description: %s\n", opt.Description) } if opt.DefaultValue != "" && opt.DefaultValue != "null" { fmt.Printf(" Default: %s\n", opt.DefaultValue) } if opt.Example != "" && opt.Example != "null" { fmt.Printf(" Example: %s\n", opt.Example) } if opt.ReadOnly { fmt.Println(" Read-only: yes") } // Get declarations declarations, err := store.GetDeclarations(ctx, opt.ID) if err == nil && len(declarations) > 0 { fmt.Println(" Declared in:") for _, decl := range declarations { if decl.Line > 0 { fmt.Printf(" - %s:%d\n", decl.FilePath, decl.Line) } else { fmt.Printf(" - %s\n", decl.FilePath) } } } // Get children children, err := store.GetChildren(ctx, rev.ID, opt.Name) if err == nil && len(children) > 0 { fmt.Println(" Sub-options:") for _, child := range children { shortName := child.Name if strings.HasPrefix(child.Name, opt.Name+".") { shortName = child.Name[len(opt.Name)+1:] } fmt.Printf(" - %s (%s)\n", shortName, child.Type) } } return nil } func runDelete(c *cli.Context, revision string) error { ctx := context.Background() store, err := openStore(c.String("database")) if err != nil { return fmt.Errorf("failed to open database: %w", err) } defer store.Close() //nolint:errcheck // cleanup on exit if err := store.Initialize(ctx); err != nil { return fmt.Errorf("failed to initialize database: %w", err) } // Find revision rev, err := store.GetRevision(ctx, revision) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } if rev == nil { rev, err = store.GetRevisionByChannel(ctx, revision) if err != nil { return fmt.Errorf("failed to get revision: %w", err) } } if rev == nil { return fmt.Errorf("revision '%s' not found", revision) } if err := store.DeleteRevision(ctx, rev.ID); err != nil { return fmt.Errorf("failed to delete revision: %w", err) } fmt.Printf("Deleted revision %s\n", rev.GitHash) return nil }