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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user