From fd40e73f1b5c1297ac0dc86313373b5230e0b101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Fri, 13 Feb 2026 22:12:08 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 3 +- README.md | 4 +- cmd/nixpkgs-search/main.go | 5 +- internal/mcp/handlers.go | 118 +++++++++++++++++++++++++++++++++++- internal/mcp/server.go | 37 +++++++++-- internal/mcp/server_test.go | 105 ++++++++++++++++++++++++++++++++ nix/package.nix | 2 +- 7 files changed, 260 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 53ab60a..cc70202 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/README.md b/README.md index 406fada..5d2e253 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/cmd/nixpkgs-search/main.go b/cmd/nixpkgs-search/main.go index 5ec1a3c..dfba47d 100644 --- a/cmd/nixpkgs-search/main.go +++ b/cmd/nixpkgs-search/main.go @@ -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 { diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 7dc66ff..7e18124 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -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) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index b236bb7..8ff2b72 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -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", diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 5ecc92f..60ce5c6 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -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) diff --git a/nix/package.nix b/nix/package.nix index 88908c2..b800ff2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -7,7 +7,7 @@ buildGoModule { inherit pname src; - version = "0.3.0"; + version = "0.4.0"; vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";