feat: add package indexing to MCP index_revision tool
The options server's index_revision now also indexes packages when running under nixpkgs-search, matching the CLI behavior. The packages server gets its own index_revision tool for standalone package indexing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -144,7 +144,7 @@ labmcp/
|
||||
| `search_options` | Full-text search across option names and descriptions |
|
||||
| `get_option` | Get full details for a specific option with children |
|
||||
| `get_file` | Fetch source file contents from indexed repository |
|
||||
| `index_revision` | Index a revision (by hash or channel name) |
|
||||
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
@@ -155,6 +155,7 @@ labmcp/
|
||||
| `search_packages` | Full-text search across package names and descriptions |
|
||||
| `get_package` | Get full details for a specific package by attr path |
|
||||
| `get_file` | Fetch source file contents from nixpkgs |
|
||||
| `index_revision` | Index a revision to make its packages searchable |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
|
||||
| `search_options` | Search for options by name or description |
|
||||
| `get_option` | Get full details for a specific option |
|
||||
| `get_file` | Fetch source file contents from the repository |
|
||||
| `index_revision` | Index a revision |
|
||||
| `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
@@ -395,7 +395,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
|
||||
| `search_packages` | Search for packages by name or description |
|
||||
| `get_package` | Get full details for a specific package |
|
||||
| `get_file` | Fetch source file contents from nixpkgs |
|
||||
| `index_revision` | Index a revision |
|
||||
| `index_revision` | Index a revision to make its packages searchable |
|
||||
| `list_revisions` | List all indexed revisions |
|
||||
| `delete_revision` | Delete an indexed revision |
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultDatabase = "sqlite://nixpkgs-search.db"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -310,7 +310,8 @@ func runOptionsServe(c *cli.Context) error {
|
||||
server := mcp.NewServer(store, logger, config)
|
||||
|
||||
indexer := nixos.NewIndexer(store)
|
||||
server.RegisterHandlers(indexer)
|
||||
pkgIndexer := packages.NewIndexer(store)
|
||||
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
|
||||
|
||||
transport := c.String("transport")
|
||||
switch transport {
|
||||
|
||||
@@ -14,12 +14,29 @@ import (
|
||||
)
|
||||
|
||||
// RegisterHandlers registers all tool handlers on the server for options mode.
|
||||
// Used by legacy nixos-options and hm-options servers (no package indexing).
|
||||
func (s *Server) RegisterHandlers(indexer options.Indexer) {
|
||||
s.registerOptionsHandlers(indexer, nil)
|
||||
}
|
||||
|
||||
// RegisterHandlersWithPackages registers all tool handlers for options mode
|
||||
// with additional package indexing support. When pkgIndexer is non-nil,
|
||||
// index_revision will also index packages, and list_revisions will show package counts.
|
||||
func (s *Server) RegisterHandlersWithPackages(indexer options.Indexer, pkgIndexer *packages.Indexer) {
|
||||
s.registerOptionsHandlers(indexer, pkgIndexer)
|
||||
}
|
||||
|
||||
// registerOptionsHandlers is the shared implementation for RegisterHandlers and RegisterHandlersWithPackages.
|
||||
func (s *Server) registerOptionsHandlers(indexer options.Indexer, pkgIndexer *packages.Indexer) {
|
||||
s.tools["search_options"] = s.handleSearchOptions
|
||||
s.tools["get_option"] = s.handleGetOption
|
||||
s.tools["get_file"] = s.handleGetFile
|
||||
s.tools["index_revision"] = s.makeIndexHandler(indexer)
|
||||
s.tools["list_revisions"] = s.handleListRevisions
|
||||
s.tools["index_revision"] = s.makeIndexHandler(indexer, pkgIndexer)
|
||||
if pkgIndexer != nil {
|
||||
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||
} else {
|
||||
s.tools["list_revisions"] = s.handleListRevisions
|
||||
}
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
|
||||
@@ -28,6 +45,7 @@ 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["index_revision"] = s.makePackageIndexHandler(pkgIndexer)
|
||||
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
|
||||
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||
}
|
||||
@@ -246,7 +264,8 @@ 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 options.Indexer) ToolHandler {
|
||||
// If pkgIndexer is non-nil, it will also index packages after options and files.
|
||||
func (s *Server) makeIndexHandler(indexer options.Indexer, pkgIndexer *packages.Indexer) ToolHandler {
|
||||
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
revision, _ := args["revision"].(string)
|
||||
if revision == "" {
|
||||
@@ -278,6 +297,17 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
||||
s.logger.Printf("Warning: file indexing failed: %v", err)
|
||||
}
|
||||
|
||||
// Index packages if package indexer is available
|
||||
var packageCount int
|
||||
if pkgIndexer != nil {
|
||||
pkgResult, pkgErr := pkgIndexer.IndexPackages(ctx, result.Revision.ID, result.Revision.GitHash)
|
||||
if pkgErr != nil {
|
||||
s.logger.Printf("Warning: package indexing failed: %v", pkgErr)
|
||||
} else {
|
||||
packageCount = pkgResult.PackageCount
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
|
||||
if result.Revision.ChannelName != "" {
|
||||
@@ -285,6 +315,9 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
|
||||
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
|
||||
if packageCount > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Packages: %d\n", packageCount))
|
||||
}
|
||||
// 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)))
|
||||
@@ -296,6 +329,85 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// makePackageIndexHandler creates an index_revision handler for the packages-only server.
|
||||
// It creates a revision record if needed, then indexes packages.
|
||||
func (s *Server) makePackageIndexHandler(pkgIndexer *packages.Indexer) ToolHandler {
|
||||
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
revision, _ := args["revision"].(string)
|
||||
if revision == "" {
|
||||
return ErrorContent(fmt.Errorf("revision is required")), nil
|
||||
}
|
||||
|
||||
if err := packages.ValidateRevision(revision); err != nil {
|
||||
return ErrorContent(err), nil
|
||||
}
|
||||
|
||||
// Resolve channel aliases to git ref
|
||||
ref := pkgIndexer.ResolveRevision(revision)
|
||||
|
||||
// Check if revision already exists
|
||||
rev, err := s.store.GetRevision(ctx, ref)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
|
||||
}
|
||||
|
||||
if rev == nil {
|
||||
// Also try by channel name
|
||||
rev, err = s.store.GetRevisionByChannel(ctx, revision)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
if rev == nil {
|
||||
// Create a new revision record
|
||||
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
|
||||
rev = &database.Revision{
|
||||
GitHash: ref,
|
||||
ChannelName: pkgIndexer.GetChannelName(revision),
|
||||
CommitDate: commitDate,
|
||||
}
|
||||
if err := s.store.CreateRevision(ctx, rev); err != nil {
|
||||
return ErrorContent(fmt.Errorf("failed to create revision: %w", err)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if packages are already indexed for this revision
|
||||
if rev.PackageCount > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Revision already indexed: %s\n", rev.GitHash))
|
||||
if rev.ChannelName != "" {
|
||||
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Packages: %d\n", rev.PackageCount))
|
||||
sb.WriteString(fmt.Sprintf("Indexed at: %s\n", rev.IndexedAt.Format("2006-01-02 15:04")))
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Index packages
|
||||
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
|
||||
if err != nil {
|
||||
return ErrorContent(fmt.Errorf("package indexing failed: %w", err)), nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", rev.GitHash))
|
||||
if rev.ChannelName != "" {
|
||||
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Packages: %d\n", pkgResult.PackageCount))
|
||||
if dur, ok := pkgResult.Duration.(time.Duration); ok {
|
||||
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
|
||||
}
|
||||
|
||||
return CallToolResult{
|
||||
Content: []Content{TextContent(sb.String())},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleListRevisions handles the list_revisions tool.
|
||||
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||
revisions, err := s.store.ListRevisions(ctx)
|
||||
|
||||
@@ -45,7 +45,7 @@ type ServerConfig struct {
|
||||
func DefaultNixOSConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixos-options",
|
||||
Version: "0.3.0",
|
||||
Version: "0.4.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModeOptions,
|
||||
@@ -57,7 +57,9 @@ If the current project contains a flake.lock file, you can index the exact nixpk
|
||||
|
||||
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.`,
|
||||
This ensures option documentation matches the nixpkgs version the project actually uses.
|
||||
|
||||
Note: index_revision also indexes packages when available, so both options and packages become searchable.`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ This ensures option documentation matches the nixpkgs version the project actual
|
||||
func DefaultNixpkgsPackagesConfig() ServerConfig {
|
||||
return ServerConfig{
|
||||
Name: "nixpkgs-packages",
|
||||
Version: "0.3.0",
|
||||
Version: "0.4.0",
|
||||
DefaultChannel: "nixos-stable",
|
||||
SourceName: "nixpkgs",
|
||||
Mode: ModePackages,
|
||||
@@ -73,7 +75,9 @@ func DefaultNixpkgsPackagesConfig() ServerConfig {
|
||||
|
||||
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)
|
||||
2. Call index_revision with that git hash to index packages for that specific version
|
||||
|
||||
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
|
||||
|
||||
This ensures package information matches the nixpkgs version the project actually uses.`,
|
||||
}
|
||||
@@ -427,7 +431,7 @@ func (s *Server) getOptionToolDefinitions() []Tool {
|
||||
},
|
||||
{
|
||||
Name: "index_revision",
|
||||
Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo),
|
||||
Description: s.indexRevisionDescription(sourceRepo),
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
@@ -464,6 +468,15 @@ func (s *Server) getOptionToolDefinitions() []Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// indexRevisionDescription returns the description for the index_revision tool,
|
||||
// adjusted based on whether packages are also indexed.
|
||||
func (s *Server) indexRevisionDescription(sourceRepo string) string {
|
||||
if s.config.SourceName == "nixpkgs" {
|
||||
return fmt.Sprintf("Index a %s revision to make its options and packages searchable", sourceRepo)
|
||||
}
|
||||
return fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo)
|
||||
}
|
||||
|
||||
// getPackageToolDefinitions returns the tool definitions for packages mode.
|
||||
func (s *Server) getPackageToolDefinitions() []Tool {
|
||||
exampleChannels := "'nixos-unstable', 'nixos-24.05'"
|
||||
@@ -547,6 +560,20 @@ func (s *Server) getPackageToolDefinitions() []Tool {
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "index_revision",
|
||||
Description: "Index a nixpkgs revision to make its packages searchable",
|
||||
InputSchema: InputSchema{
|
||||
Type: "object",
|
||||
Properties: map[string]Property{
|
||||
"revision": {
|
||||
Type: "string",
|
||||
Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
|
||||
},
|
||||
},
|
||||
Required: []string{"revision"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_revisions",
|
||||
Description: "List all indexed nixpkgs revisions",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
||||
"git.t-juice.club/torjus/labmcp/internal/packages"
|
||||
)
|
||||
|
||||
func TestServerInitialize(t *testing.T) {
|
||||
@@ -145,6 +146,110 @@ func TestServerNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackagesServerToolsList(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil, DefaultNixpkgsPackagesConfig())
|
||||
|
||||
pkgIndexer := packages.NewIndexer(store)
|
||||
server.RegisterPackageHandlers(pkgIndexer)
|
||||
|
||||
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||
|
||||
resp := runRequest(t, server, input)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("Unexpected error: %v", resp.Error)
|
||||
}
|
||||
|
||||
result, ok := resp.Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", resp.Result)
|
||||
}
|
||||
|
||||
tools, ok := result["tools"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected tools array, got %T", result["tools"])
|
||||
}
|
||||
|
||||
// Should have 6 tools (search_packages, get_package, get_file, index_revision, list_revisions, delete_revision)
|
||||
if len(tools) != 6 {
|
||||
t.Errorf("Expected 6 tools, got %d", len(tools))
|
||||
}
|
||||
|
||||
expectedTools := map[string]bool{
|
||||
"search_packages": false,
|
||||
"get_package": false,
|
||||
"get_file": false,
|
||||
"index_revision": false,
|
||||
"list_revisions": false,
|
||||
"delete_revision": false,
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
toolMap := tool.(map[string]interface{})
|
||||
name := toolMap["name"].(string)
|
||||
if _, ok := expectedTools[name]; ok {
|
||||
expectedTools[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for name, found := range expectedTools {
|
||||
if !found {
|
||||
t.Errorf("Tool %q not found in tools list", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionsServerWithPackagesToolsList(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := NewServer(store, nil, DefaultNixOSConfig())
|
||||
|
||||
indexer := nixos.NewIndexer(store)
|
||||
pkgIndexer := packages.NewIndexer(store)
|
||||
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
|
||||
|
||||
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
|
||||
|
||||
resp := runRequest(t, server, input)
|
||||
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("Unexpected error: %v", resp.Error)
|
||||
}
|
||||
|
||||
result, ok := resp.Result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected map result, got %T", resp.Result)
|
||||
}
|
||||
|
||||
tools, ok := result["tools"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected tools array, got %T", result["tools"])
|
||||
}
|
||||
|
||||
// Should still have 6 tools (same as options-only)
|
||||
if len(tools) != 6 {
|
||||
t.Errorf("Expected 6 tools, got %d", len(tools))
|
||||
}
|
||||
|
||||
// Verify index_revision is present
|
||||
found := false
|
||||
for _, tool := range tools {
|
||||
toolMap := tool.(map[string]interface{})
|
||||
if toolMap["name"].(string) == "index_revision" {
|
||||
found = true
|
||||
// For nixpkgs source, description should mention packages
|
||||
desc := toolMap["description"].(string)
|
||||
if !strings.Contains(desc, "packages") {
|
||||
t.Errorf("index_revision description should mention packages, got: %s", desc)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("index_revision tool not found in tools list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilePathValidation(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
server := setupTestServer(t, store)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
buildGoModule {
|
||||
inherit pname src;
|
||||
version = "0.3.0";
|
||||
version = "0.4.0";
|
||||
|
||||
vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user