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 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:51:30 +01:00
parent 6b6be83e50
commit ea2d73d746
15 changed files with 1693 additions and 58 deletions

View File

@@ -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-<hash>/)
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
}

View File

@@ -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<bar", true},
{"injection curly braces", "foo{bar}", true},
{"injection parens", "foo(bar)", true},
{"injection brackets", "foo[bar]", true},
// Edge cases
{"empty string", "", true},
{"too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
{"just dots", "...", false}, // dots are allowed, path traversal is handled elsewhere
{"single char", "a", false},
{"max length 64", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
{"65 chars", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRevision(tt.revision)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRevision(%q) error = %v, wantErr %v", tt.revision, err, tt.wantErr)
}
})
}
}
// TestResolveRevision tests channel alias resolution.
func TestResolveRevision(t *testing.T) {
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
indexer := NewIndexer(store)
tests := []struct {
input string
expected string
}{
{"hm-unstable", "master"},
{"hm-stable", "release-24.11"},
{"master", "master"},
{"release-24.11", "release-24.11"},
{"release-24.05", "release-24.05"},
{"release-23.11", "release-23.11"},
{"abc123def", "abc123def"}, // Git hash passes through
{"unknown-channel", "unknown-channel"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := indexer.ResolveRevision(tt.input)
if result != tt.expected {
t.Errorf("ResolveRevision(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// TestGetChannelName tests channel name lookup.
func TestGetChannelName(t *testing.T) {
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
indexer := NewIndexer(store)
tests := []struct {
input string
expected string
}{
{"hm-unstable", "hm-unstable"},
{"hm-stable", "hm-stable"},
{"master", "master"}, // "master" is both an alias and a ref
{"release-24.11", "release-24.11"},
{"abc123def", ""}, // Git hash has no channel name
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := indexer.GetChannelName(tt.input)
if result != tt.expected {
t.Errorf("GetChannelName(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// BenchmarkIndexRevision benchmarks indexing a full home-manager revision.
// This is a slow benchmark that requires nix to be installed.
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/homemanager/...
func BenchmarkIndexRevision(b *testing.B) {
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
b.Skip("nix-build not found, skipping indexer benchmark")
}
// Use in-memory SQLite for the benchmark
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
b.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
b.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Delete any existing revision first (for repeated runs)
if rev, _ := store.GetRevision(ctx, TestHomeManagerRevision); rev != nil {
store.DeleteRevision(ctx, rev.ID)
}
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
if err != nil {
b.Fatalf("IndexRevision failed: %v", err)
}
b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.(time.Duration).Milliseconds()), "ms")
}
}
// TestIndexRevision is an integration test for the indexer.
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/homemanager/...
func TestIndexRevision(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Check if nix-build is available
if _, err := exec.LookPath("nix-build"); err != nil {
t.Skip("nix-build not found, skipping indexer test")
}
store, err := database.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
defer store.Close()
ctx := context.Background()
if err := store.Initialize(ctx); err != nil {
t.Fatalf("Failed to initialize store: %v", err)
}
indexer := NewIndexer(store)
t.Logf("Indexing home-manager revision %s...", TestHomeManagerRevision)
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
if err != nil {
t.Fatalf("IndexRevision failed: %v", err)
}
t.Logf("Indexed %d options in %s", result.OptionCount, result.Duration)
// Verify we got a reasonable number of options (Home Manager has hundreds)
if result.OptionCount < 100 {
t.Errorf("Expected at least 100 options, got %d", result.OptionCount)
}
// Verify revision was stored
rev, err := store.GetRevision(ctx, TestHomeManagerRevision)
if err != nil {
t.Fatalf("GetRevision failed: %v", err)
}
if rev == nil {
t.Fatal("Revision not found after indexing")
}
if rev.OptionCount != result.OptionCount {
t.Errorf("Stored option count %d != result count %d", rev.OptionCount, result.OptionCount)
}
// Test searching for git options (programs.git is a common HM option)
options, err := store.SearchOptions(ctx, rev.ID, "git", database.SearchFilters{Limit: 10})
if err != nil {
t.Fatalf("SearchOptions failed: %v", err)
}
if len(options) == 0 {
t.Error("Expected to find git options")
}
t.Logf("Found %d git options", len(options))
// Test getting a specific option
opt, err := store.GetOption(ctx, rev.ID, "programs.git.enable")
if err != nil {
t.Fatalf("GetOption failed: %v", err)
}
if opt == nil {
t.Error("programs.git.enable not found")
} else {
t.Logf("programs.git.enable: type=%s", opt.Type)
if opt.Type != "boolean" {
t.Errorf("Expected type 'boolean', got %q", opt.Type)
}
}
// Test getting children
children, err := store.GetChildren(ctx, rev.ID, "programs.git")
if err != nil {
t.Fatalf("GetChildren failed: %v", err)
}
if len(children) == 0 {
t.Error("Expected programs.git to have children")
}
t.Logf("programs.git has %d direct children", len(children))
}

View File

@@ -0,0 +1,24 @@
// Package homemanager contains types and logic specific to Home Manager options.
package homemanager
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
var ChannelAliases = map[string]string{
"hm-unstable": "master",
"hm-stable": "release-24.11",
"master": "master",
"release-24.11": "release-24.11",
"release-24.05": "release-24.05",
"release-23.11": "release-23.11",
"release-23.05": "release-23.05",
}
// AllowedExtensions is the default set of file extensions to index.
var AllowedExtensions = map[string]bool{
".nix": true,
".json": true,
".md": true,
".txt": true,
".toml": true,
".yaml": true,
".yml": true,
}

View File

@@ -9,11 +9,11 @@ import (
"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"
)
// RegisterHandlers registers all tool handlers on the server.
func (s *Server) RegisterHandlers(indexer *nixos.Indexer) {
func (s *Server) RegisterHandlers(indexer options.Indexer) {
s.tools["search_options"] = s.handleSearchOptions
s.tools["get_option"] = s.handleGetOption
s.tools["get_file"] = s.handleGetFile
@@ -213,7 +213,7 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{})
}
// makeIndexHandler creates the index_revision handler with the indexer.
func (s *Server) makeIndexHandler(indexer *nixos.Indexer) ToolHandler {
func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string)
if revision == "" {
@@ -252,7 +252,10 @@ func (s *Server) makeIndexHandler(indexer *nixos.Indexer) ToolHandler {
}
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
sb.WriteString(fmt.Sprintf("Duration: %s\n", result.Duration.Round(time.Millisecond)))
// Handle Duration which may be time.Duration or interface{}
if dur, ok := result.Duration.(time.Duration); ok {
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
@@ -316,8 +319,12 @@ func (s *Server) handleDeleteRevision(ctx context.Context, args map[string]inter
// resolveRevision resolves a revision string to a Revision object.
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
if revision == "" {
// Try to find a default revision
rev, err := s.store.GetRevisionByChannel(ctx, "nixos-stable")
// Try to find a default revision using config
defaultChannel := s.config.DefaultChannel
if defaultChannel == "" {
defaultChannel = "nixos-stable" // fallback for backwards compatibility
}
rev, err := s.store.GetRevisionByChannel(ctx, defaultChannel)
if err != nil {
return nil, err
}

View File

@@ -10,9 +10,62 @@ import (
"git.t-juice.club/torjus/labmcp/internal/database"
)
// ServerConfig contains configuration for the MCP server.
type ServerConfig struct {
// Name is the server name reported in initialization.
Name string
// Version is the server version.
Version string
// Instructions are the server instructions sent to clients.
Instructions string
// DefaultChannel is the default channel to use when no revision is specified.
DefaultChannel string
// SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager").
SourceName string
}
// DefaultNixOSConfig returns the default configuration for NixOS options server.
func DefaultNixOSConfig() ServerConfig {
return ServerConfig{
Name: "nixos-options",
Version: "0.1.0",
DefaultChannel: "nixos-stable",
SourceName: "nixpkgs",
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the nixpkgs version the project actually uses.`,
}
}
// DefaultHomeManagerConfig returns the default configuration for Home Manager options server.
func DefaultHomeManagerConfig() ServerConfig {
return ServerConfig{
Name: "hm-options",
Version: "0.1.0",
DefaultChannel: "hm-stable",
SourceName: "home-manager",
Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options.
If the current project contains a flake.lock file, you can index the exact home-manager revision used by the project:
1. Read the flake.lock file to find the home-manager "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the home-manager version the project actually uses.`,
}
}
// Server is an MCP server that handles JSON-RPC requests.
type Server struct {
store database.Store
config ServerConfig
tools map[string]ToolHandler
initialized bool
logger *log.Logger
@@ -21,13 +74,14 @@ type Server struct {
// ToolHandler is a function that handles a tool call.
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
// NewServer creates a new MCP server.
func NewServer(store database.Store, logger *log.Logger) *Server {
// NewServer creates a new MCP server with the given configuration.
func NewServer(store database.Store, logger *log.Logger, config ServerConfig) *Server {
if logger == nil {
logger = log.New(io.Discard, "", 0)
}
s := &Server{
store: store,
config: config,
tools: make(map[string]ToolHandler),
logger: logger,
}
@@ -126,18 +180,10 @@ func (s *Server) handleInitialize(req *Request) *Response {
},
},
ServerInfo: Implementation{
Name: "nixos-options",
Version: "0.1.0",
Name: s.config.Name,
Version: s.config.Version,
},
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
If the current project contains a flake.lock file, you can index the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field
2. Call index_revision with that git hash to index options for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the nixpkgs version the project actually uses.`,
Instructions: s.config.Instructions,
}
return &Response{
@@ -159,10 +205,27 @@ func (s *Server) handleToolsList(req *Request) *Response {
// getToolDefinitions returns the tool definitions.
func (s *Server) getToolDefinitions() []Tool {
// Determine naming based on source
optionType := "NixOS"
sourceRepo := "nixpkgs"
exampleOption := "services.nginx.enable"
exampleNamespace := "services.nginx"
exampleFilePath := "nixos/modules/services/web-servers/nginx/default.nix"
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
if s.config.SourceName == "home-manager" {
optionType = "Home Manager"
sourceRepo = "home-manager"
exampleOption = "programs.git.enable"
exampleNamespace = "programs.git"
exampleFilePath = "modules/programs/git.nix"
exampleChannels = "'hm-unstable', 'release-24.11'"
}
return []Tool{
{
Name: "search_options",
Description: "Search for NixOS configuration options by name or description",
Description: fmt.Sprintf("Search for %s configuration options by name or description", optionType),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
@@ -172,7 +235,7 @@ func (s *Server) getToolDefinitions() []Tool {
},
"revision": {
Type: "string",
Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.",
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
},
"type": {
Type: "string",
@@ -180,7 +243,7 @@ func (s *Server) getToolDefinitions() []Tool {
},
"namespace": {
Type: "string",
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
Description: fmt.Sprintf("Filter by namespace prefix (e.g., '%s')", exampleNamespace),
},
"limit": {
Type: "integer",
@@ -193,13 +256,13 @@ func (s *Server) getToolDefinitions() []Tool {
},
{
Name: "get_option",
Description: "Get full details for a specific NixOS option including its children",
Description: fmt.Sprintf("Get full details for a specific %s option including its children", optionType),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"name": {
Type: "string",
Description: "Full option path (e.g., 'services.nginx.enable')",
Description: fmt.Sprintf("Full option path (e.g., '%s')", exampleOption),
},
"revision": {
Type: "string",
@@ -216,13 +279,13 @@ func (s *Server) getToolDefinitions() []Tool {
},
{
Name: "get_file",
Description: "Fetch the contents of a file from nixpkgs",
Description: fmt.Sprintf("Fetch the contents of a file from %s", sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"path": {
Type: "string",
Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')",
Description: fmt.Sprintf("File path relative to %s root (e.g., '%s')", sourceRepo, exampleFilePath),
},
"revision": {
Type: "string",
@@ -234,13 +297,13 @@ func (s *Server) getToolDefinitions() []Tool {
},
{
Name: "index_revision",
Description: "Index a nixpkgs revision to make its options searchable",
Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')",
Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
},
},
Required: []string{"revision"},
@@ -248,7 +311,7 @@ func (s *Server) getToolDefinitions() []Tool {
},
{
Name: "list_revisions",
Description: "List all indexed nixpkgs revisions",
Description: fmt.Sprintf("List all indexed %s revisions", sourceRepo),
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{},

View File

@@ -14,7 +14,7 @@ import (
func TestServerInitialize(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
@@ -41,7 +41,7 @@ func TestServerInitialize(t *testing.T) {
func TestServerToolsList(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
@@ -93,7 +93,7 @@ func TestServerToolsList(t *testing.T) {
func TestServerMethodNotFound(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}`
@@ -110,7 +110,7 @@ func TestServerMethodNotFound(t *testing.T) {
func TestServerParseError(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
input := `not valid json`
@@ -127,7 +127,7 @@ func TestServerParseError(t *testing.T) {
func TestServerNotification(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
// Notification (no response expected)
input := `{"jsonrpc":"2.0","method":"notifications/initialized"}`
@@ -254,7 +254,7 @@ func setupTestStore(t *testing.T) database.Store {
func setupTestServer(t *testing.T, store database.Store) *Server {
t.Helper()
server := NewServer(store, nil)
server := NewServer(store, nil, DefaultNixOSConfig())
indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer)

View File

@@ -15,7 +15,7 @@ import (
// testHTTPTransport creates a transport with a test server
func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) {
// Use a mock store
server := NewServer(nil, log.New(io.Discard, "", 0))
server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
if config.SessionTTL == 0 {
config.SessionTTL = 30 * time.Minute
@@ -459,7 +459,7 @@ func TestHTTPTransportSSEKeepalive(t *testing.T) {
}
func TestHTTPTransportSSEKeepaliveDisabled(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0))
server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
config := HTTPConfig{
SSEKeepAlive: -1, // Explicitly disabled
}
@@ -545,7 +545,7 @@ func TestHTTPTransportOptionsRequest(t *testing.T) {
}
func TestHTTPTransportDefaultConfig(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0))
server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
transport := NewHTTPTransport(server, HTTPConfig{})
// Verify defaults are applied
@@ -581,7 +581,7 @@ func TestHTTPTransportDefaultConfig(t *testing.T) {
}
func TestHTTPTransportCustomConfig(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0))
server := NewServer(nil, log.New(io.Discard, "", 0), DefaultNixOSConfig())
config := HTTPConfig{
Address: "0.0.0.0:9090",
Endpoint: "/api/mcp",

View File

@@ -16,6 +16,7 @@ import (
"time"
"git.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options"
)
// revisionPattern validates revision strings to prevent injection attacks.
@@ -40,13 +41,8 @@ func NewIndexer(store database.Store) *Indexer {
}
// 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)
}
// Deprecated: Use options.IndexResult instead.
type IndexResult = options.IndexResult
// ValidateRevision checks if a revision string is safe to use.
// Returns an error if the revision contains potentially dangerous characters.
@@ -305,7 +301,30 @@ func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, e
return commit.Commit.Committer.Date, nil
}
// resolveRevision resolves a channel name or ref to a git ref.
// 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 ""
}
// resolveRevision is a helper that calls the method.
func resolveRevision(revision string) string {
// Check if it's a known channel alias
if ref, ok := ChannelAliases[revision]; ok {
@@ -314,7 +333,7 @@ func resolveRevision(revision string) string {
return revision
}
// getChannelName returns the channel name if the revision matches one.
// getChannelName is a helper that returns the channel name.
func getChannelName(revision string) string {
if _, ok := ChannelAliases[revision]; ok {
return revision

View File

@@ -99,7 +99,9 @@ func BenchmarkIndexRevision(b *testing.B) {
}
b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.Milliseconds()), "ms")
if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "ms")
}
}
}
@@ -146,7 +148,9 @@ func BenchmarkIndexRevisionWithFiles(b *testing.B) {
fileDuration := time.Since(fileStart)
b.ReportMetric(float64(result.OptionCount), "options")
b.ReportMetric(float64(result.Duration.Milliseconds()), "options_ms")
if dur, ok := result.Duration.(time.Duration); ok {
b.ReportMetric(float64(dur.Milliseconds()), "options_ms")
}
b.ReportMetric(float64(fileCount), "files")
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
}

View File

@@ -0,0 +1,37 @@
// Package options provides shared types and interfaces for options indexers.
package options
import (
"context"
"git.t-juice.club/torjus/labmcp/internal/database"
)
// IndexResult contains the results of an indexing operation.
type IndexResult struct {
Revision *database.Revision
OptionCount int
FileCount int
Duration interface{} // time.Duration - kept as interface to avoid import cycle
AlreadyIndexed bool // True if revision was already indexed (skipped)
}
// Indexer is the interface for options indexers.
// Both NixOS and Home Manager indexers implement this interface.
type Indexer interface {
// IndexRevision indexes a revision by git hash or channel name.
// Returns AlreadyIndexed=true if the revision was already indexed.
IndexRevision(ctx context.Context, revision string) (*IndexResult, error)
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
ReindexRevision(ctx context.Context, revision string) (*IndexResult, error)
// IndexFiles indexes files from the source repository tarball.
IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error)
// ResolveRevision resolves a channel name or ref to a git ref.
ResolveRevision(revision string) string
// GetChannelName returns the channel name if the revision matches one.
GetChannelName(revision string) string
}