From ea2d73d746dd6b994847dd552a2b967239632c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 22:51:30 +0100 Subject: [PATCH 1/4] feat: add hm-options package for Home Manager options Add a new MCP server for Home Manager options, mirroring the functionality of nixos-options but targeting the home-manager repository. Changes: - Add shared options.Indexer interface for both implementations - Add internal/homemanager package with indexer and channel aliases - Add cmd/hm-options CLI entry point - Parameterize MCP server with ServerConfig for name/instructions - Parameterize nix/package.nix for building both packages - Add hm-options package and NixOS module to flake.nix - Add nix/hm-options-module.nix for systemd deployment Co-Authored-By: Claude Opus 4.5 --- cmd/hm-options/main.go | 521 +++++++++++++++++++++++++++ cmd/nixos-options/main.go | 3 +- flake.nix | 11 + internal/homemanager/indexer.go | 428 ++++++++++++++++++++++ internal/homemanager/indexer_test.go | 256 +++++++++++++ internal/homemanager/types.go | 24 ++ internal/mcp/handlers.go | 19 +- internal/mcp/server.go | 109 ++++-- internal/mcp/server_test.go | 12 +- internal/mcp/transport_http_test.go | 8 +- internal/nixos/indexer.go | 37 +- internal/nixos/indexer_test.go | 8 +- internal/options/indexer.go | 37 ++ nix/hm-options-module.nix | 261 ++++++++++++++ nix/package.nix | 17 +- 15 files changed, 1693 insertions(+), 58 deletions(-) create mode 100644 cmd/hm-options/main.go create mode 100644 internal/homemanager/indexer.go create mode 100644 internal/homemanager/indexer_test.go create mode 100644 internal/homemanager/types.go create mode 100644 internal/options/indexer.go create mode 100644 nix/hm-options-module.nix diff --git a/cmd/hm-options/main.go b/cmd/hm-options/main.go new file mode 100644 index 0000000..af3d466 --- /dev/null +++ b/cmd/hm-options/main.go @@ -0,0 +1,521 @@ +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/homemanager" + "git.t-juice.club/torjus/labmcp/internal/mcp" + "git.t-juice.club/torjus/labmcp/internal/options" +) + +const ( + defaultDatabase = "sqlite://hm-options.db" + version = "0.1.0" +) + +func main() { + app := &cli.App{ + Name: "hm-options", + Usage: "MCP server for Home Manager 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{"HM_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 home-manager 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() + + 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.DefaultHomeManagerConfig() + server := mcp.NewServer(store, logger, config) + + indexer := homemanager.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() + + if err := store.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + indexer := homemanager.NewIndexer(store) + + fmt.Printf("Indexing revision: %s\n", revision) + + var result *options.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 + } + + if dur, ok := result.Duration.(time.Duration); ok { + fmt.Printf("Indexed %d options in %s\n", result.OptionCount, dur) + } else { + fmt.Printf("Indexed %d options\n", result.OptionCount) + } + 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() + + 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 'hm-options index ' to index a home-manager 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() + + 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() + + 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() + + 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 +} diff --git a/cmd/nixos-options/main.go b/cmd/nixos-options/main.go index b9e7451..c926367 100644 --- a/cmd/nixos-options/main.go +++ b/cmd/nixos-options/main.go @@ -197,7 +197,8 @@ func runServe(c *cli.Context) error { } logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) - server := mcp.NewServer(store, logger) + config := mcp.DefaultNixOSConfig() + server := mcp.NewServer(store, logger, config) indexer := nixos.NewIndexer(store) server.RegisterHandlers(indexer) diff --git a/flake.nix b/flake.nix index fe9e11e..0112d33 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,13 @@ in { nixos-options = pkgs.callPackage ./nix/package.nix { src = ./.; }; + hm-options = pkgs.callPackage ./nix/package.nix { + src = ./.; + pname = "hm-options-mcp"; + subPackage = "cmd/hm-options"; + mainProgram = "hm-options"; + description = "MCP server for Home Manager options search and query"; + }; default = self.packages.${system}.nixos-options; }); @@ -50,6 +57,10 @@ imports = [ ./nix/module.nix ]; services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options; }; + hm-options-mcp = { pkgs, ... }: { + imports = [ ./nix/hm-options-module.nix ]; + services.hm-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.hm-options; + }; default = self.nixosModules.nixos-options-mcp; }; }; diff --git a/internal/homemanager/indexer.go b/internal/homemanager/indexer.go new file mode 100644 index 0000000..4a46745 --- /dev/null +++ b/internal/homemanager/indexer.go @@ -0,0 +1,428 @@ +package homemanager + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "git.t-juice.club/torjus/labmcp/internal/database" + "git.t-juice.club/torjus/labmcp/internal/nixos" + "git.t-juice.club/torjus/labmcp/internal/options" +) + +// revisionPattern validates revision strings to prevent injection attacks. +// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "release-24.11" +// and git hashes). Must be 1-64 characters. +var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) + +// Indexer handles indexing of home-manager revisions. +type Indexer struct { + store database.Store + httpClient *http.Client +} + +// NewIndexer creates a new Home Manager indexer. +func NewIndexer(store database.Store) *Indexer { + return &Indexer{ + store: store, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// IndexResult contains the results of an indexing operation. +type IndexResult struct { + Revision *database.Revision + OptionCount int + FileCount int + Duration time.Duration + AlreadyIndexed bool // True if revision was already indexed (skipped) +} + +// ValidateRevision checks if a revision string is safe to use. +// Returns an error if the revision contains potentially dangerous characters. +func ValidateRevision(revision string) error { + if !revisionPattern.MatchString(revision) { + return fmt.Errorf("invalid revision format: must be 1-64 alphanumeric characters, hyphens, underscores, or dots") + } + return nil +} + +// IndexRevision indexes a home-manager revision by git hash or channel name. +func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*options.IndexResult, error) { + start := time.Now() + + // Validate revision to prevent injection attacks + if err := ValidateRevision(revision); err != nil { + return nil, err + } + + // Resolve channel names to git refs + ref := idx.ResolveRevision(revision) + + // Check if already indexed + existing, err := idx.store.GetRevision(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to check existing revision: %w", err) + } + if existing != nil { + return &options.IndexResult{ + Revision: existing, + OptionCount: existing.OptionCount, + Duration: time.Since(start), + AlreadyIndexed: true, + }, nil + } + + // Build options.json using nix + optionsPath, cleanup, err := idx.buildOptions(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to build options: %w", err) + } + defer cleanup() + + // Parse options.json (reuse nixos parser - same format) + optionsFile, err := os.Open(optionsPath) + if err != nil { + return nil, fmt.Errorf("failed to open options.json: %w", err) + } + defer optionsFile.Close() + + opts, err := nixos.ParseOptions(optionsFile) + if err != nil { + return nil, fmt.Errorf("failed to parse options: %w", err) + } + + // Get commit info + commitDate, err := idx.getCommitDate(ctx, ref) + if err != nil { + // Non-fatal, use current time + commitDate = time.Now() + } + + // Create revision record + rev := &database.Revision{ + GitHash: ref, + ChannelName: idx.GetChannelName(revision), + CommitDate: commitDate, + OptionCount: len(opts), + } + if err := idx.store.CreateRevision(ctx, rev); err != nil { + return nil, fmt.Errorf("failed to create revision: %w", err) + } + + // Store options + if err := idx.storeOptions(ctx, rev.ID, opts); err != nil { + // Cleanup on failure + idx.store.DeleteRevision(ctx, rev.ID) + return nil, fmt.Errorf("failed to store options: %w", err) + } + + return &options.IndexResult{ + Revision: rev, + OptionCount: len(opts), + Duration: time.Since(start), + }, nil +} + +// ReindexRevision forces re-indexing of a revision, deleting existing data first. +func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*options.IndexResult, error) { + // Validate revision to prevent injection attacks + if err := ValidateRevision(revision); err != nil { + return nil, err + } + + ref := idx.ResolveRevision(revision) + + // Delete existing revision if present + existing, err := idx.store.GetRevision(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to check existing revision: %w", err) + } + if existing != nil { + if err := idx.store.DeleteRevision(ctx, existing.ID); err != nil { + return nil, fmt.Errorf("failed to delete existing revision: %w", err) + } + } + + // Now index fresh + return idx.IndexRevision(ctx, revision) +} + +// buildOptions builds options.json for a home-manager revision. +func (idx *Indexer) buildOptions(ctx context.Context, ref string) (string, func(), error) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "hm-options-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + cleanup := func() { + os.RemoveAll(tmpDir) + } + + // Build options.json using nix-build + // This evaluates the Home Manager options from the specified revision + nixExpr := fmt.Sprintf(` + let + hm = builtins.fetchTarball { + url = "https://github.com/nix-community/home-manager/archive/%s.tar.gz"; + }; + nixpkgs = builtins.fetchTarball { + url = "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz"; + }; + pkgs = import nixpkgs { config = {}; }; + lib = import (hm + "/modules/lib/stdlib-extended.nix") pkgs.lib; + docs = import (hm + "/docs") { inherit pkgs lib; release = "24.11"; isReleaseBranch = false; }; + in docs.options.json + `, ref) + + cmd := exec.CommandContext(ctx, "nix-build", "--no-out-link", "-E", nixExpr) + cmd.Dir = tmpDir + + output, err := cmd.Output() + if err != nil { + cleanup() + if exitErr, ok := err.(*exec.ExitError); ok { + return "", nil, fmt.Errorf("nix-build failed: %s", string(exitErr.Stderr)) + } + return "", nil, fmt.Errorf("nix-build failed: %w", err) + } + + // The output is the store path containing share/doc/home-manager/options.json + storePath := strings.TrimSpace(string(output)) + optionsPath := filepath.Join(storePath, "share", "doc", "home-manager", "options.json") + + if _, err := os.Stat(optionsPath); err != nil { + cleanup() + return "", nil, fmt.Errorf("options.json not found at %s", optionsPath) + } + + return optionsPath, cleanup, nil +} + +// storeOptions stores parsed options in the database. +func (idx *Indexer) storeOptions(ctx context.Context, revisionID int64, opts map[string]*nixos.ParsedOption) error { + // Prepare batch of options + dbOpts := make([]*database.Option, 0, len(opts)) + declsByName := make(map[string][]*database.Declaration) + + for name, opt := range opts { + dbOpt := &database.Option{ + RevisionID: revisionID, + Name: name, + ParentPath: database.ParentPath(name), + Type: opt.Type, + DefaultValue: opt.Default, + Example: opt.Example, + Description: opt.Description, + ReadOnly: opt.ReadOnly, + } + dbOpts = append(dbOpts, dbOpt) + + // Prepare declarations for this option + decls := make([]*database.Declaration, 0, len(opt.Declarations)) + for _, path := range opt.Declarations { + decls = append(decls, &database.Declaration{ + FilePath: path, + }) + } + declsByName[name] = decls + } + + // Store options in batches + batchSize := 1000 + for i := 0; i < len(dbOpts); i += batchSize { + end := i + batchSize + if end > len(dbOpts) { + end = len(dbOpts) + } + batch := dbOpts[i:end] + + if err := idx.store.CreateOptionsBatch(ctx, batch); err != nil { + return fmt.Errorf("failed to store options batch: %w", err) + } + } + + // Store declarations + for _, opt := range dbOpts { + decls := declsByName[opt.Name] + for _, decl := range decls { + decl.OptionID = opt.ID + } + if len(decls) > 0 { + if err := idx.store.CreateDeclarationsBatch(ctx, decls); err != nil { + return fmt.Errorf("failed to store declarations for %s: %w", opt.Name, err) + } + } + } + + return nil +} + +// getCommitDate gets the commit date for a git ref. +func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, error) { + // Use GitHub API to get commit info + url := fmt.Sprintf("https://api.github.com/repos/nix-community/home-manager/commits/%s", ref) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return time.Time{}, err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := idx.httpClient.Do(req) + if err != nil { + return time.Time{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var commit struct { + Commit struct { + Committer struct { + Date time.Time `json:"date"` + } `json:"committer"` + } `json:"commit"` + } + + if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil { + return time.Time{}, err + } + + return commit.Commit.Committer.Date, nil +} + +// ResolveRevision resolves a channel name or ref to a git ref. +func (idx *Indexer) ResolveRevision(revision string) string { + // Check if it's a known channel alias + if ref, ok := ChannelAliases[revision]; ok { + return ref + } + return revision +} + +// GetChannelName returns the channel name if the revision matches one. +func (idx *Indexer) GetChannelName(revision string) string { + if _, ok := ChannelAliases[revision]; ok { + return revision + } + // Check if the revision is a channel ref value + for name, ref := range ChannelAliases { + if ref == revision { + return name + } + } + return "" +} + +// IndexFiles indexes files from a home-manager tarball. +// This is a separate operation that can be run after IndexRevision. +func (idx *Indexer) IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error) { + // Download home-manager tarball + url := fmt.Sprintf("https://github.com/nix-community/home-manager/archive/%s.tar.gz", ref) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := idx.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to download tarball: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Extract and index files + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + count := 0 + batch := make([]*database.File, 0, 100) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return count, fmt.Errorf("tar read error: %w", err) + } + + // Skip directories + if header.Typeflag != tar.TypeReg { + continue + } + + // Check file extension + ext := filepath.Ext(header.Name) + if !AllowedExtensions[ext] { + continue + } + + // Skip very large files (> 1MB) + if header.Size > 1024*1024 { + continue + } + + // Remove the top-level directory (home-manager-/) + path := header.Name + if i := strings.Index(path, "/"); i >= 0 { + path = path[i+1:] + } + + // Read content + content, err := io.ReadAll(tr) + if err != nil { + continue + } + + file := &database.File{ + RevisionID: revisionID, + FilePath: path, + Extension: ext, + Content: string(content), + } + batch = append(batch, file) + count++ + + // Store in batches + if len(batch) >= 100 { + if err := idx.store.CreateFilesBatch(ctx, batch); err != nil { + return count, fmt.Errorf("failed to store files batch: %w", err) + } + batch = batch[:0] + } + } + + // Store remaining files + if len(batch) > 0 { + if err := idx.store.CreateFilesBatch(ctx, batch); err != nil { + return count, fmt.Errorf("failed to store final files batch: %w", err) + } + } + + return count, nil +} diff --git a/internal/homemanager/indexer_test.go b/internal/homemanager/indexer_test.go new file mode 100644 index 0000000..d1de049 --- /dev/null +++ b/internal/homemanager/indexer_test.go @@ -0,0 +1,256 @@ +package homemanager + +import ( + "context" + "os/exec" + "testing" + "time" + + "git.t-juice.club/torjus/labmcp/internal/database" +) + +// TestHomeManagerRevision is a known release branch for testing. +const TestHomeManagerRevision = "release-24.11" + +// TestValidateRevision tests the revision validation function. +func TestValidateRevision(t *testing.T) { + tests := []struct { + name string + revision string + wantErr bool + }{ + // Valid cases + {"valid git hash", "abc123def456abc123def456abc123def456abc1", false}, + {"valid short hash", "abc123d", false}, + {"valid channel name", "hm-unstable", false}, + {"valid release", "release-24.11", false}, + {"valid master", "master", false}, + {"valid underscore", "some_branch", false}, + {"valid mixed", "release-24.05_beta", false}, + + // Invalid cases - injection attempts + {"injection semicolon", "foo; rm -rf /", true}, + {"injection quotes", `"; builtins.readFile /etc/passwd; "`, true}, + {"injection backticks", "foo`whoami`", true}, + {"injection dollar", "foo$(whoami)", true}, + {"injection newline", "foo\nbar", true}, + {"injection space", "foo bar", true}, + {"injection slash", "foo/bar", true}, + {"injection backslash", "foo\\bar", true}, + {"injection pipe", "foo|bar", true}, + {"injection ampersand", "foo&bar", true}, + {"injection redirect", "foo>bar", true}, + {"injection less than", "foo&2 + exit 1 + fi + export HM_OPTIONS_DATABASE="$(cat "${cfg.database.connectionStringFile}")" + + ${indexCommands} + exec hm-options serve ${httpFlags} + '' else '' + ${indexCommands} + exec hm-options serve ${httpFlags} + ''; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + RestartSec = "5s"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + + ReadWritePaths = [ cfg.dataDir ]; + WorkingDirectory = cfg.dataDir; + StateDirectory = lib.mkIf (cfg.dataDir == "/var/lib/hm-options-mcp") "hm-options-mcp"; + }; + }; + + # Open firewall for HTTP port if configured + networking.firewall = lib.mkIf cfg.openFirewall (let + # Extract port from address (format: "host:port" or ":port") + addressParts = lib.splitString ":" cfg.http.address; + port = lib.toInt (lib.last addressParts); + in { + allowedTCPPorts = [ port ]; + }); + }; +} diff --git a/nix/package.nix b/nix/package.nix index aedab46..449b1e1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,26 +1,29 @@ -{ lib, buildGoModule, makeWrapper, nix, src }: +{ lib, buildGoModule, makeWrapper, nix, src +, pname ? "nixos-options-mcp" +, subPackage ? "cmd/nixos-options" +, mainProgram ? "nixos-options" +, description ? "MCP server for NixOS options search and query" +}: buildGoModule { - pname = "nixos-options-mcp"; + inherit pname src; version = "0.1.0"; - inherit src; vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; - subPackages = [ "cmd/nixos-options" ]; + subPackages = [ subPackage ]; nativeBuildInputs = [ makeWrapper ]; postInstall = '' - wrapProgram $out/bin/nixos-options \ + wrapProgram $out/bin/${mainProgram} \ --prefix PATH : ${lib.makeBinPath [ nix ]} ''; meta = with lib; { - description = "MCP server for NixOS options search and query"; + inherit description mainProgram; homepage = "https://git.t-juice.club/torjus/labmcp"; license = licenses.mit; maintainers = [ ]; - mainProgram = "nixos-options"; }; } -- 2.49.1 From 11935db702cb984a1fc4a988e496657831b8401f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 23:03:36 +0100 Subject: [PATCH 2/4] docs: update README and CLAUDE.md for hm-options, bump version to 0.1.1 - Add hm-options documentation to README.md - Update CLAUDE.md with hm-options info, repository structure - Add note about git-tracking new files before nix build/run - Add version bump rules documentation - Bump version from 0.1.0 to 0.1.1 (patch bump for internal/ changes) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 101 ++++++++++++++++++------ README.md | 157 +++++++++++++++++++++++--------------- cmd/hm-options/main.go | 2 +- cmd/nixos-options/main.go | 2 +- internal/mcp/server.go | 4 +- nix/package.nix | 2 +- 6 files changed, 181 insertions(+), 87 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3eeb19d..1120268 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,20 @@ This file provides context for Claude when working on this project. **LabMCP** is a collection of Model Context Protocol (MCP) servers written in Go, designed to extend Claude's capabilities with custom tools. The repository is structured to be generic and extensible, allowing multiple MCP servers to be added over time. -## Current Focus: NixOS Options MCP Server +## MCP Servers -The first MCP server provides search and query capabilities for NixOS configuration options. This addresses the challenge of incomplete or hard-to-find documentation in the Nix ecosystem. +### NixOS Options (`nixos-options`) +Search and query NixOS configuration options. Uses nixpkgs as source. + +### Home Manager Options (`hm-options`) +Search and query Home Manager configuration options. Uses home-manager repository as source. + +Both servers share the same architecture: +- Full-text search across option names and descriptions +- Query specific options with type, default, example, and declarations +- Index multiple revisions (by git hash or channel name) +- Fetch module source files +- PostgreSQL and SQLite backends ## Technology Stack @@ -21,9 +32,9 @@ The first MCP server provides search and query capabilities for NixOS configurat ## Project Status **Complete and maintained** - All core features implemented: -- Full MCP server with 6 tools +- Full MCP servers with 6 tools each - PostgreSQL and SQLite backends with FTS -- NixOS module for deployment +- NixOS modules for deployment - CLI for manual operations - Comprehensive test suite @@ -32,8 +43,10 @@ The first MCP server provides search and query capabilities for NixOS configurat ``` labmcp/ ├── cmd/ -│ └── nixos-options/ -│ └── main.go # CLI entry point +│ ├── nixos-options/ +│ │ └── main.go # NixOS options CLI +│ └── hm-options/ +│ └── main.go # Home Manager options CLI ├── internal/ │ ├── database/ │ │ ├── interface.go # Store interface @@ -42,7 +55,7 @@ labmcp/ │ │ ├── sqlite.go # SQLite implementation │ │ └── *_test.go # Database tests │ ├── mcp/ -│ │ ├── server.go # MCP server core +│ │ ├── server.go # MCP server core + ServerConfig │ │ ├── handlers.go # Tool implementations │ │ ├── types.go # Protocol types │ │ ├── transport.go # Transport interface @@ -50,14 +63,21 @@ labmcp/ │ │ ├── transport_http.go # HTTP/SSE transport │ │ ├── session.go # HTTP session management │ │ └── *_test.go # MCP tests -│ └── nixos/ -│ ├── indexer.go # Nixpkgs indexing -│ ├── parser.go # options.json parsing +│ ├── options/ +│ │ └── indexer.go # Shared Indexer interface +│ ├── nixos/ +│ │ ├── indexer.go # Nixpkgs indexing +│ │ ├── parser.go # options.json parsing (shared) +│ │ ├── types.go # Channel aliases, extensions +│ │ └── *_test.go # Indexer tests +│ └── homemanager/ +│ ├── indexer.go # Home Manager indexing │ ├── types.go # Channel aliases, extensions │ └── *_test.go # Indexer tests ├── nix/ -│ ├── module.nix # NixOS module -│ └── package.nix # Nix package definition +│ ├── module.nix # NixOS module for nixos-options +│ ├── hm-options-module.nix # NixOS module for hm-options +│ └── package.nix # Parameterized Nix package ├── testdata/ │ └── options-sample.json # Test fixture ├── flake.nix @@ -70,14 +90,14 @@ labmcp/ ## MCP Tools -All tools are implemented and functional: +Both servers provide the same 6 tools: | Tool | Description | |------|-------------| | `search_options` | Full-text search across option names and descriptions | | `get_option` | Get full details for a specific option with children | -| `get_file` | Fetch source file contents from indexed nixpkgs | -| `index_revision` | Index a nixpkgs revision (by hash or channel name) | +| `get_file` | Fetch source file contents from indexed repository | +| `index_revision` | Index a revision (by hash or channel name) | | `list_revisions` | List all indexed revisions | | `delete_revision` | Delete an indexed revision | @@ -90,7 +110,7 @@ All tools are implemented and functional: - Batch operations for efficient indexing ### Indexing -- Uses `nix-build` to evaluate NixOS options from any nixpkgs revision +- Uses `nix-build` to evaluate options from any revision - File indexing downloads tarball and stores allowed extensions (.nix, .json, .md, etc.) - File indexing enabled by default (use `--no-files` to skip) - Skips already-indexed revisions (use `--force` to re-index) @@ -116,12 +136,10 @@ All tools are implemented and functional: ## CLI Commands +### nixos-options ```bash nixos-options serve # Run MCP server on STDIO (default) nixos-options serve --transport http # Run MCP server on HTTP -nixos-options serve --transport http \ - --http-address 0.0.0.0:8080 \ - --allowed-origins https://example.com # HTTP with custom config nixos-options index # Index a nixpkgs revision nixos-options index --force # Force re-index existing revision nixos-options index --no-files # Skip file content indexing @@ -132,6 +150,26 @@ nixos-options delete # Delete indexed revision nixos-options --version # Show version ``` +### hm-options +```bash +hm-options serve # Run MCP server on STDIO (default) +hm-options serve --transport http # Run MCP server on HTTP +hm-options index # Index a home-manager revision +hm-options index --force # Force re-index existing revision +hm-options index --no-files # Skip file content indexing +hm-options list # List indexed revisions +hm-options search # Search options +hm-options get