feat: add nixpkgs-search binary with package search support
Add a new nixpkgs-search CLI that combines NixOS options search with Nix package search functionality. This provides two MCP servers from a single binary: - `nixpkgs-search options serve` for NixOS options - `nixpkgs-search packages serve` for Nix packages Key changes: - Add packages table to database schema (version 3) - Add Package type and search methods to database layer - Create internal/packages/ with indexer and parser for nix-env JSON - Add MCP server mode (options/packages) with separate tool sets - Add package handlers: search_packages, get_package - Create cmd/nixpkgs-search with combined indexing support - Update flake.nix with nixpkgs-search package (now default) - Bump version to 0.2.0 The index command can index both options and packages together, or use --no-packages/--no-options flags for partial indexing. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,10 @@ import (
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
||||
)
|
||||
|
||||
// RegisterHandlers registers all tool handlers on the server.
|
||||
// RegisterHandlers registers all tool handlers on the server for options mode.
|
||||
func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||
s.tools["search_options"] = s.handleSearchOptions
|
||||
s.tools["get_option"] = s.handleGetOption
|
||||
@@ -22,6 +23,15 @@ func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
|
||||
// RegisterPackageHandlers registers all tool handlers on the server for packages mode.
|
||||
func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
|
||||
s.tools["search_packages"] = s.handleSearchPackages
|
||||
s.tools["get_package"] = s.handleGetPackage
|
||||
s.tools["get_file"] = s.handleGetFile
|
||||
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
|
||||
// handleSearchOptions handles the search_options tool.
|
||||
func (s *Server) handleSearchOptions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
query, _ := args["query"].(string)
|
||||
@@ -420,3 +430,196 @@ func formatJSON(s string) string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// handleSearchPackages handles the search_packages tool.
|
||||
func (s *Server) handleSearchPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return ErrorContent(fmt.Errorf("query is required")), nil
|
||||
}
|
||||
|
||||
revision, _ := args["revision"].(string)
|
||||
rev, err := s.resolveRevision(ctx, revision)
|
||||
if err != nil {
|
||||
return ErrorContent(err), nil
|
||||
}
|
||||
if rev == nil {
|
||||
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||
}
|
||||
|
||||
filters := database.PackageSearchFilters{
|
||||
Limit: 50,
|
||||
}
|
||||
|
||||
if broken, ok := args["broken"].(bool); ok {
|
||||
filters.Broken = &broken
|
||||
}
|
||||
if unfree, ok := args["unfree"].(bool); ok {
|
||||
filters.Unfree = &unfree
|
||||
}
|
||||
if limit, ok := args["limit"].(float64); ok && limit > 0 {
|
||||
filters.Limit = int(limit)
|
||||
}
|
||||
|
||||
pkgs, err := s.store.SearchPackages(ctx, rev.ID, query, filters)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("search failed: %w", err)), nil
|
||||
}
|
||||
|
||||
// Format results
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d packages matching '%s' in revision %s:\n\n", len(pkgs), query, rev.GitHash[:8]))
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
sb.WriteString(fmt.Sprintf("## %s\n", pkg.AttrPath))
|
||||
sb.WriteString(fmt.Sprintf("**Name:** %s", pkg.Pname))
|
||||
if pkg.Version != "" {
|
||||
sb.WriteString(fmt.Sprintf(" %s", pkg.Version))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if pkg.Description != "" {
|
||||
desc := pkg.Description
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Description:** %s\n", desc))
|
||||
}
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
var flags []string
|
||||
if pkg.Broken {
|
||||
flags = append(flags, "broken")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
flags = append(flags, "unfree")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
flags = append(flags, "insecure")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Flags:** %s\n", strings.Join(flags, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleGetPackage handles the get_package tool.
|
||||
func (s *Server) handleGetPackage(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
attrPath, _ := args["attr_path"].(string)
|
||||
if attrPath == "" {
|
||||
return ErrorContent(fmt.Errorf("attr_path is required")), nil
|
||||
}
|
||||
|
||||
revision, _ := args["revision"].(string)
|
||||
rev, err := s.resolveRevision(ctx, revision)
|
||||
if err != nil {
|
||||
return ErrorContent(err), nil
|
||||
}
|
||||
if rev == nil {
|
||||
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||
}
|
||||
|
||||
pkg, err := s.store.GetPackage(ctx, rev.ID, attrPath)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to get package: %w", err)), nil
|
||||
}
|
||||
if pkg == nil {
|
||||
return ErrorContent(fmt.Errorf("package '%s' not found", attrPath)), nil
|
||||
}
|
||||
|
||||
// Format result
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("# %s\n\n", pkg.AttrPath))
|
||||
sb.WriteString(fmt.Sprintf("**Package name:** %s\n", pkg.Pname))
|
||||
if pkg.Version != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Version:** %s\n", pkg.Version))
|
||||
}
|
||||
|
||||
if pkg.Description != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", pkg.Description))
|
||||
}
|
||||
|
||||
if pkg.LongDescription != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Long description:**\n%s\n", pkg.LongDescription))
|
||||
}
|
||||
|
||||
if pkg.Homepage != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Homepage:** %s\n", pkg.Homepage))
|
||||
}
|
||||
|
||||
if pkg.License != "" && pkg.License != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**License:** %s\n", formatJSONArray(pkg.License)))
|
||||
}
|
||||
|
||||
if pkg.Maintainers != "" && pkg.Maintainers != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Maintainers:** %s\n", formatJSONArray(pkg.Maintainers)))
|
||||
}
|
||||
|
||||
if pkg.Platforms != "" && pkg.Platforms != "[]" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Platforms:** %s\n", formatJSONArray(pkg.Platforms)))
|
||||
}
|
||||
|
||||
// Status flags
|
||||
if pkg.Broken || pkg.Unfree || pkg.Insecure {
|
||||
sb.WriteString("\n**Status:**\n")
|
||||
if pkg.Broken {
|
||||
sb.WriteString("- ⚠️ This package is marked as **broken**\n")
|
||||
}
|
||||
if pkg.Unfree {
|
||||
sb.WriteString("- This package has an **unfree** license\n")
|
||||
}
|
||||
if pkg.Insecure {
|
||||
sb.WriteString("- ⚠️ This package is marked as **insecure**\n")
|
||||
}
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleListRevisionsWithPackages handles the list_revisions tool for packages mode.
|
||||
func (s *Server) handleListRevisionsWithPackages(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
revisions, err := s.store.ListRevisions(ctx)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to list revisions: %w", err)), nil
|
||||
}
|
||||
|
||||
if len(revisions) == 0 {
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent("No revisions indexed. Use the nixpkgs-search CLI to index packages.")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Indexed revisions (%d):\n\n", len(revisions)))
|
||||
|
||||
for _, rev := range revisions {
|
||||
sb.WriteString(fmt.Sprintf("- **%s**", rev.GitHash[:12]))
|
||||
if rev.ChannelName != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", rev.ChannelName))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n Options: %d, Packages: %d, Indexed: %s\n",
|
||||
rev.OptionCount, rev.PackageCount, rev.IndexedAt.Format("2006-01-02 15:04")))
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatJSONArray formats a JSON array string as a comma-separated list.
|
||||
func formatJSONArray(s string) string {
|
||||
if s == "" || s == "[]" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(s), &arr); err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return strings.Join(arr, ", ")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,16 @@ import (
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
)
|
||||
|
||||
// ServerMode indicates which type of tools the server should expose.
|
||||
type ServerMode string
|
||||
|
||||
const (
|
||||
// ModeOptions exposes only option-related tools.
|
||||
ModeOptions ServerMode = "options"
|
||||
// ModePackages exposes only package-related tools.
|
||||
ModePackages ServerMode = "packages"
|
||||
)
|
||||
|
||||
// ServerConfig contains configuration for the MCP server.
|
||||
type ServerConfig struct {
|
||||
// Name is the server name reported in initialization.
|
||||
@@ -22,15 +32,18 @@ type ServerConfig struct {
|
||||
DefaultChannel string
|
||||
// SourceName is the name of the source repository (e.g., "nixpkgs", "home-manager").
|
||||
SourceName string
|
||||
// Mode specifies which tools to expose (options or packages).
|
||||
Mode ServerMode
|
||||
}
|
||||
|
||||
// DefaultNixOSConfig returns the default configuration for NixOS options server.
|
||||
func DefaultNixOSConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixos-options",
|
||||
Version: "0.1.2",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModeOptions,
|
||||
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:
|
||||
@@ -43,13 +56,32 @@ This ensures option documentation matches the nixpkgs version the project actual
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultNixpkgsPackagesConfig returns the default configuration for nixpkgs packages server.
|
||||
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixpkgs-packages",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModePackages,
|
||||
Instructions: `Nixpkgs Packages MCP Server - Search and query Nix packages from nixpkgs.
|
||||
|
||||
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
|
||||
1. Read the flake.lock file to find the nixpkgs "rev" field
|
||||
2. Ensure the revision is indexed (packages are indexed separately from options)
|
||||
|
||||
This ensures package information 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.2",
|
||||
Version: "0.2.0",
|
||||
DefaultChannel: "hm-stable",
|
||||
SourceName: "home-manager",
|
||||
Mode: ModeOptions,
|
||||
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:
|
||||
@@ -205,6 +237,17 @@ func (s *Server) handleToolsList(req *Request) *Response {
|
||||
|
||||
// getToolDefinitions returns the tool definitions.
|
||||
func (s *Server) getToolDefinitions() []Tool {
|
||||
// For packages mode, return package tools
|
||||
if s.config.Mode == ModePackages {
|
||||
return s.getPackageToolDefinitions()
|
||||
}
|
||||
|
||||
// Default: options mode
|
||||
return s.getOptionToolDefinitions()
|
||||
}
|
||||
|
||||
// getOptionToolDefinitions returns the tool definitions for options mode.
|
||||
func (s *Server) getOptionToolDefinitions() []Tool {
|
||||
// Determine naming based on source
|
||||
optionType := "NixOS"
|
||||
sourceRepo := "nixpkgs"
|
||||
@@ -344,6 +387,114 @@ func (s *Server) getToolDefinitions() []Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
||||
func (s *Server) getPackageToolDefinitions() []Tool {
|
||||
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
||||
exampleFilePath := "pkgs/applications/networking/browsers/firefox/default.nix"
|
||||
|
||||
return []Tool{
|
||||
{
|
||||
Name: "search_packages",
|
||||
Description: "Search for Nix packages by name or description",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Search query (matches package name, attr path, and description)",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf("Git hash or channel name (e.g., %s). Uses default if not specified.", exampleChannels),
|
||||
},
|
||||
"broken": {
|
||||
Type: "boolean",
|
||||
Description: "Filter by broken status (true = only broken, false = only working)",
|
||||
},
|
||||
"unfree": {
|
||||
Type: "boolean",
|
||||
Description: "Filter by license (true = only unfree, false = only free)",
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum number of results (default: 50)",
|
||||
Default: 50,
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_package",
|
||||
Description: "Get full details for a specific Nix package by attribute path",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"attr_path": {
|
||||
Type: "string",
|
||||
Description: "Package attribute path (e.g., 'firefox', 'python312Packages.requests')",
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
},
|
||||
Required: []string{"attr_path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_file",
|
||||
Description: "Fetch the contents of a file from nixpkgs",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf("File path relative to nixpkgs root (e.g., '%s')", exampleFilePath),
|
||||
},
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name. Uses default if not specified.",
|
||||
},
|
||||
"offset": {
|
||||
Type: "integer",
|
||||
Description: "Line offset (0-based). Default: 0",
|
||||
Default: 0,
|
||||
},
|
||||
"limit": {
|
||||
Type: "integer",
|
||||
Description: "Maximum lines to return. Default: 250, use 0 for all lines",
|
||||
Default: 250,
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_revisions",
|
||||
Description: "List all indexed nixpkgs revisions",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_revision",
|
||||
Description: "Delete an indexed revision and all its data",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: "Git hash or channel name of the revision to delete",
|
||||
},
|
||||
},
|
||||
Required: []string{"revision"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolsCall handles a tool invocation.
|
||||
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
|
||||
var params CallToolParams
|
||||
|
||||
Reference in New Issue
Block a user