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:
521
cmd/hm-options/main.go
Normal file
521
cmd/hm-options/main.go
Normal file
@@ -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: "<revision>",
|
||||||
|
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: "<query>",
|
||||||
|
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: "<option-name>",
|
||||||
|
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: "<revision>",
|
||||||
|
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 <revision>' 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
|
||||||
|
}
|
||||||
@@ -197,7 +197,8 @@ func runServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags)
|
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)
|
indexer := nixos.NewIndexer(store)
|
||||||
server.RegisterHandlers(indexer)
|
server.RegisterHandlers(indexer)
|
||||||
|
|||||||
11
flake.nix
11
flake.nix
@@ -19,6 +19,13 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
nixos-options = pkgs.callPackage ./nix/package.nix { src = ./.; };
|
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;
|
default = self.packages.${system}.nixos-options;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +57,10 @@
|
|||||||
imports = [ ./nix/module.nix ];
|
imports = [ ./nix/module.nix ];
|
||||||
services.nixos-options-mcp.package = lib.mkDefault self.packages.${pkgs.system}.nixos-options;
|
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;
|
default = self.nixosModules.nixos-options-mcp;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
428
internal/homemanager/indexer.go
Normal file
428
internal/homemanager/indexer.go
Normal 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
|
||||||
|
}
|
||||||
256
internal/homemanager/indexer_test.go
Normal file
256
internal/homemanager/indexer_test.go
Normal 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))
|
||||||
|
}
|
||||||
24
internal/homemanager/types.go
Normal file
24
internal/homemanager/types.go
Normal 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,
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"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.
|
// 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["search_options"] = s.handleSearchOptions
|
||||||
s.tools["get_option"] = s.handleGetOption
|
s.tools["get_option"] = s.handleGetOption
|
||||||
s.tools["get_file"] = s.handleGetFile
|
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.
|
// 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) {
|
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
revision, _ := args["revision"].(string)
|
revision, _ := args["revision"].(string)
|
||||||
if revision == "" {
|
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("Options: %d\n", result.OptionCount))
|
||||||
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
|
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{
|
return CallToolResult{
|
||||||
Content: []Content{TextContent(sb.String())},
|
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.
|
// resolveRevision resolves a revision string to a Revision object.
|
||||||
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
|
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
|
||||||
if revision == "" {
|
if revision == "" {
|
||||||
// Try to find a default revision
|
// Try to find a default revision using config
|
||||||
rev, err := s.store.GetRevisionByChannel(ctx, "nixos-stable")
|
defaultChannel := s.config.DefaultChannel
|
||||||
|
if defaultChannel == "" {
|
||||||
|
defaultChannel = "nixos-stable" // fallback for backwards compatibility
|
||||||
|
}
|
||||||
|
rev, err := s.store.GetRevisionByChannel(ctx, defaultChannel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,62 @@ import (
|
|||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"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.
|
// Server is an MCP server that handles JSON-RPC requests.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store database.Store
|
store database.Store
|
||||||
|
config ServerConfig
|
||||||
tools map[string]ToolHandler
|
tools map[string]ToolHandler
|
||||||
initialized bool
|
initialized bool
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -21,13 +74,14 @@ type Server struct {
|
|||||||
// ToolHandler is a function that handles a tool call.
|
// ToolHandler is a function that handles a tool call.
|
||||||
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
|
type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error)
|
||||||
|
|
||||||
// NewServer creates a new MCP server.
|
// NewServer creates a new MCP server with the given configuration.
|
||||||
func NewServer(store database.Store, logger *log.Logger) *Server {
|
func NewServer(store database.Store, logger *log.Logger, config ServerConfig) *Server {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = log.New(io.Discard, "", 0)
|
logger = log.New(io.Discard, "", 0)
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
store: store,
|
store: store,
|
||||||
|
config: config,
|
||||||
tools: make(map[string]ToolHandler),
|
tools: make(map[string]ToolHandler),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
@@ -126,18 +180,10 @@ func (s *Server) handleInitialize(req *Request) *Response {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
ServerInfo: Implementation{
|
ServerInfo: Implementation{
|
||||||
Name: "nixos-options",
|
Name: s.config.Name,
|
||||||
Version: "0.1.0",
|
Version: s.config.Version,
|
||||||
},
|
},
|
||||||
Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options.
|
Instructions: s.config.Instructions,
|
||||||
|
|
||||||
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.`,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Response{
|
return &Response{
|
||||||
@@ -159,10 +205,27 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
|||||||
|
|
||||||
// getToolDefinitions returns the tool definitions.
|
// getToolDefinitions returns the tool definitions.
|
||||||
func (s *Server) getToolDefinitions() []Tool {
|
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{
|
return []Tool{
|
||||||
{
|
{
|
||||||
Name: "search_options",
|
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{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{
|
Properties: map[string]Property{
|
||||||
@@ -172,7 +235,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
"revision": {
|
"revision": {
|
||||||
Type: "string",
|
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": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -180,7 +243,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
"namespace": {
|
"namespace": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Filter by namespace prefix (e.g., 'services.nginx')",
|
Description: fmt.Sprintf("Filter by namespace prefix (e.g., '%s')", exampleNamespace),
|
||||||
},
|
},
|
||||||
"limit": {
|
"limit": {
|
||||||
Type: "integer",
|
Type: "integer",
|
||||||
@@ -193,13 +256,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "get_option",
|
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{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{
|
Properties: map[string]Property{
|
||||||
"name": {
|
"name": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
Description: "Full option path (e.g., 'services.nginx.enable')",
|
Description: fmt.Sprintf("Full option path (e.g., '%s')", exampleOption),
|
||||||
},
|
},
|
||||||
"revision": {
|
"revision": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -216,13 +279,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "get_file",
|
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{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{
|
Properties: map[string]Property{
|
||||||
"path": {
|
"path": {
|
||||||
Type: "string",
|
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": {
|
"revision": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
@@ -234,13 +297,13 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "index_revision",
|
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{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{
|
Properties: map[string]Property{
|
||||||
"revision": {
|
"revision": {
|
||||||
Type: "string",
|
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"},
|
Required: []string{"revision"},
|
||||||
@@ -248,7 +311,7 @@ func (s *Server) getToolDefinitions() []Tool {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "list_revisions",
|
Name: "list_revisions",
|
||||||
Description: "List all indexed nixpkgs revisions",
|
Description: fmt.Sprintf("List all indexed %s revisions", sourceRepo),
|
||||||
InputSchema: InputSchema{
|
InputSchema: InputSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: map[string]Property{},
|
Properties: map[string]Property{},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
func TestServerInitialize(t *testing.T) {
|
func TestServerInitialize(t *testing.T) {
|
||||||
store := setupTestStore(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"}}}`
|
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) {
|
func TestServerToolsList(t *testing.T) {
|
||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
server := NewServer(store, nil)
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
|
|
||||||
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
|
|
||||||
func TestServerMethodNotFound(t *testing.T) {
|
func TestServerMethodNotFound(t *testing.T) {
|
||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
server := NewServer(store, nil)
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
|
|
||||||
input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}`
|
input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}`
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ func TestServerMethodNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestServerParseError(t *testing.T) {
|
func TestServerParseError(t *testing.T) {
|
||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
server := NewServer(store, nil)
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
|
|
||||||
input := `not valid json`
|
input := `not valid json`
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ func TestServerParseError(t *testing.T) {
|
|||||||
|
|
||||||
func TestServerNotification(t *testing.T) {
|
func TestServerNotification(t *testing.T) {
|
||||||
store := setupTestStore(t)
|
store := setupTestStore(t)
|
||||||
server := NewServer(store, nil)
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
|
|
||||||
// Notification (no response expected)
|
// Notification (no response expected)
|
||||||
input := `{"jsonrpc":"2.0","method":"notifications/initialized"}`
|
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 {
|
func setupTestServer(t *testing.T, store database.Store) *Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
server := NewServer(store, nil)
|
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||||
indexer := nixos.NewIndexer(store)
|
indexer := nixos.NewIndexer(store)
|
||||||
server.RegisterHandlers(indexer)
|
server.RegisterHandlers(indexer)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// testHTTPTransport creates a transport with a test server
|
// testHTTPTransport creates a transport with a test server
|
||||||
func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) {
|
func testHTTPTransport(t *testing.T, config HTTPConfig) (*HTTPTransport, *httptest.Server) {
|
||||||
// Use a mock store
|
// 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 {
|
if config.SessionTTL == 0 {
|
||||||
config.SessionTTL = 30 * time.Minute
|
config.SessionTTL = 30 * time.Minute
|
||||||
@@ -459,7 +459,7 @@ func TestHTTPTransportSSEKeepalive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPTransportSSEKeepaliveDisabled(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{
|
config := HTTPConfig{
|
||||||
SSEKeepAlive: -1, // Explicitly disabled
|
SSEKeepAlive: -1, // Explicitly disabled
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ func TestHTTPTransportOptionsRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPTransportDefaultConfig(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{})
|
transport := NewHTTPTransport(server, HTTPConfig{})
|
||||||
|
|
||||||
// Verify defaults are applied
|
// Verify defaults are applied
|
||||||
@@ -581,7 +581,7 @@ func TestHTTPTransportDefaultConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHTTPTransportCustomConfig(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{
|
config := HTTPConfig{
|
||||||
Address: "0.0.0.0:9090",
|
Address: "0.0.0.0:9090",
|
||||||
Endpoint: "/api/mcp",
|
Endpoint: "/api/mcp",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
// revisionPattern validates revision strings to prevent injection attacks.
|
// 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.
|
// IndexResult contains the results of an indexing operation.
|
||||||
type IndexResult struct {
|
// Deprecated: Use options.IndexResult instead.
|
||||||
Revision *database.Revision
|
type IndexResult = options.IndexResult
|
||||||
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.
|
// ValidateRevision checks if a revision string is safe to use.
|
||||||
// Returns an error if the revision contains potentially dangerous characters.
|
// 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
|
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 {
|
func resolveRevision(revision string) string {
|
||||||
// Check if it's a known channel alias
|
// Check if it's a known channel alias
|
||||||
if ref, ok := ChannelAliases[revision]; ok {
|
if ref, ok := ChannelAliases[revision]; ok {
|
||||||
@@ -314,7 +333,7 @@ func resolveRevision(revision string) string {
|
|||||||
return revision
|
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 {
|
func getChannelName(revision string) string {
|
||||||
if _, ok := ChannelAliases[revision]; ok {
|
if _, ok := ChannelAliases[revision]; ok {
|
||||||
return revision
|
return revision
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ func BenchmarkIndexRevision(b *testing.B) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.ReportMetric(float64(result.OptionCount), "options")
|
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)
|
fileDuration := time.Since(fileStart)
|
||||||
|
|
||||||
b.ReportMetric(float64(result.OptionCount), "options")
|
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(fileCount), "files")
|
||||||
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
|
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
|
||||||
}
|
}
|
||||||
|
|||||||
37
internal/options/indexer.go
Normal file
37
internal/options/indexer.go
Normal 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
|
||||||
|
}
|
||||||
261
nix/hm-options-module.nix
Normal file
261
nix/hm-options-module.nix
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.hm-options-mcp;
|
||||||
|
|
||||||
|
# Determine database URL based on configuration
|
||||||
|
# For postgres with connectionStringFile, the URL is set at runtime via script
|
||||||
|
useConnectionStringFile = cfg.database.type == "postgres" && cfg.database.connectionStringFile != null;
|
||||||
|
|
||||||
|
databaseUrl = if cfg.database.type == "sqlite"
|
||||||
|
then "sqlite://${cfg.dataDir}/${cfg.database.name}"
|
||||||
|
else if useConnectionStringFile
|
||||||
|
then "" # Will be set at runtime from file
|
||||||
|
else cfg.database.connectionString;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.hm-options-mcp = {
|
||||||
|
enable = lib.mkEnableOption "Home Manager Options MCP server";
|
||||||
|
|
||||||
|
package = lib.mkPackageOption pkgs "hm-options-mcp" { };
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "hm-options-mcp";
|
||||||
|
description = "User account under which the service runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "hm-options-mcp";
|
||||||
|
description = "Group under which the service runs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = lib.mkOption {
|
||||||
|
type = lib.types.path;
|
||||||
|
default = "/var/lib/hm-options-mcp";
|
||||||
|
description = "Directory to store data files.";
|
||||||
|
};
|
||||||
|
|
||||||
|
database = {
|
||||||
|
type = lib.mkOption {
|
||||||
|
type = lib.types.enum [ "sqlite" "postgres" ];
|
||||||
|
default = "sqlite";
|
||||||
|
description = "Database backend to use.";
|
||||||
|
};
|
||||||
|
|
||||||
|
name = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "hm-options.db";
|
||||||
|
description = "SQLite database filename (when using sqlite backend).";
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionString = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = ''
|
||||||
|
PostgreSQL connection string (when using postgres backend).
|
||||||
|
Example: "postgres://user:password@localhost/hm_options?sslmode=disable"
|
||||||
|
|
||||||
|
WARNING: This value will be stored in the Nix store, which is world-readable.
|
||||||
|
For production use with sensitive credentials, use connectionStringFile instead.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
connectionStringFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Path to a file containing the PostgreSQL connection string.
|
||||||
|
The file should contain just the connection string, e.g.:
|
||||||
|
postgres://user:password@localhost/hm_options?sslmode=disable
|
||||||
|
|
||||||
|
This is the recommended way to configure PostgreSQL credentials
|
||||||
|
as the file is not stored in the world-readable Nix store.
|
||||||
|
The file must be readable by the service user.
|
||||||
|
'';
|
||||||
|
example = "/run/secrets/hm-options-mcp-db";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
indexOnStart = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "hm-unstable" "release-24.11" ];
|
||||||
|
description = ''
|
||||||
|
List of home-manager revisions to index on service start.
|
||||||
|
Can be channel names (hm-unstable) or git hashes.
|
||||||
|
Indexing is skipped if the revision is already indexed.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
http = {
|
||||||
|
address = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "127.0.0.1:8081";
|
||||||
|
description = "HTTP listen address for the MCP server.";
|
||||||
|
};
|
||||||
|
|
||||||
|
endpoint = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/mcp";
|
||||||
|
description = "HTTP endpoint path for MCP requests.";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedOrigins = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "http://localhost:3000" "https://example.com" ];
|
||||||
|
description = ''
|
||||||
|
Allowed Origin headers for CORS.
|
||||||
|
Empty list means only localhost origins are allowed.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionTTL = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "30m";
|
||||||
|
description = "Session TTL for HTTP transport (Go duration format).";
|
||||||
|
};
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
enable = lib.mkEnableOption "TLS for HTTP transport";
|
||||||
|
|
||||||
|
certFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to TLS certificate file.";
|
||||||
|
};
|
||||||
|
|
||||||
|
keyFile = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to TLS private key file.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to open the firewall for the MCP HTTP server.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = cfg.database.type == "sqlite"
|
||||||
|
|| cfg.database.connectionString != ""
|
||||||
|
|| cfg.database.connectionStringFile != null;
|
||||||
|
message = "services.hm-options-mcp.database: when using postgres backend, either connectionString or connectionStringFile must be set";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = cfg.database.connectionString == "" || cfg.database.connectionStringFile == null;
|
||||||
|
message = "services.hm-options-mcp.database: connectionString and connectionStringFile are mutually exclusive";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = !cfg.http.tls.enable || (cfg.http.tls.certFile != null && cfg.http.tls.keyFile != null);
|
||||||
|
message = "services.hm-options-mcp.http.tls: both certFile and keyFile must be set when TLS is enabled";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
users.users.${cfg.user} = lib.mkIf (cfg.user == "hm-options-mcp") {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = cfg.dataDir;
|
||||||
|
description = "Home Manager Options MCP server user";
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = lib.mkIf (cfg.group == "hm-options-mcp") { };
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.hm-options-mcp = {
|
||||||
|
description = "Home Manager Options MCP Server";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ]
|
||||||
|
++ lib.optional (cfg.database.type == "postgres") "postgresql.service";
|
||||||
|
|
||||||
|
environment = lib.mkIf (!useConnectionStringFile) {
|
||||||
|
HM_OPTIONS_DATABASE = databaseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
path = [ cfg.package ];
|
||||||
|
|
||||||
|
script = let
|
||||||
|
indexCommands = lib.optionalString (cfg.indexOnStart != []) ''
|
||||||
|
${lib.concatMapStringsSep "\n" (rev: ''
|
||||||
|
echo "Indexing revision: ${rev}"
|
||||||
|
hm-options index "${rev}" || true
|
||||||
|
'') cfg.indexOnStart}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Build HTTP transport flags
|
||||||
|
httpFlags = lib.concatStringsSep " " ([
|
||||||
|
"--transport http"
|
||||||
|
"--http-address '${cfg.http.address}'"
|
||||||
|
"--http-endpoint '${cfg.http.endpoint}'"
|
||||||
|
"--session-ttl '${cfg.http.sessionTTL}'"
|
||||||
|
] ++ lib.optionals (cfg.http.allowedOrigins != []) (
|
||||||
|
map (origin: "--allowed-origins '${origin}'") cfg.http.allowedOrigins
|
||||||
|
) ++ lib.optionals cfg.http.tls.enable [
|
||||||
|
"--tls-cert '${cfg.http.tls.certFile}'"
|
||||||
|
"--tls-key '${cfg.http.tls.keyFile}'"
|
||||||
|
]);
|
||||||
|
in
|
||||||
|
if useConnectionStringFile then ''
|
||||||
|
# Read database connection string from file
|
||||||
|
if [ ! -f "${cfg.database.connectionStringFile}" ]; then
|
||||||
|
echo "Error: connectionStringFile not found: ${cfg.database.connectionStringFile}" >&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 ];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
buildGoModule {
|
||||||
pname = "nixos-options-mcp";
|
inherit pname src;
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
inherit src;
|
|
||||||
|
|
||||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
||||||
|
|
||||||
subPackages = [ "cmd/nixos-options" ];
|
subPackages = [ subPackage ];
|
||||||
|
|
||||||
nativeBuildInputs = [ makeWrapper ];
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
wrapProgram $out/bin/nixos-options \
|
wrapProgram $out/bin/${mainProgram} \
|
||||||
--prefix PATH : ${lib.makeBinPath [ nix ]}
|
--prefix PATH : ${lib.makeBinPath [ nix ]}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
meta = with lib; {
|
meta = with lib; {
|
||||||
description = "MCP server for NixOS options search and query";
|
inherit description mainProgram;
|
||||||
homepage = "https://git.t-juice.club/torjus/labmcp";
|
homepage = "https://git.t-juice.club/torjus/labmcp";
|
||||||
license = licenses.mit;
|
license = licenses.mit;
|
||||||
maintainers = [ ];
|
maintainers = [ ];
|
||||||
mainProgram = "nixos-options";
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user