diff --git a/README.md b/README.md index 9ac3cdb6..6687a59d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The MCP Registry service provides a centralized repository for MCP server entrie ## Features - RESTful API for managing MCP registry entries (list, get, create, update, delete) +- **Keyword search** across server names and descriptions with case-insensitive matching - Health check endpoint for service monitoring - Support for various environment configurations - Graceful shutdown handling @@ -104,11 +105,12 @@ Returns the health status of the service: GET /v0/servers ``` -Lists MCP registry server entries with pagination support. +Lists MCP registry server entries with pagination support. Supports keyword search across server names and descriptions. Query parameters: - `limit`: Maximum number of entries to return (default: 30, max: 100) - `cursor`: Pagination cursor for retrieving next set of results +- `search`: Search keyword to filter servers by name and description (case-insensitive) Response example: ```json @@ -130,6 +132,23 @@ Response example: } ``` +##### Search Examples + +Search for servers containing "redis": +``` +GET /v0/servers?search=redis +``` + +Search with pagination: +``` +GET /v0/servers?search=database&limit=10&cursor=123e4567-e89b-12d3-a456-426614174000 +``` + +Case-insensitive search for "API": +``` +GET /v0/servers?search=API +``` + #### Get Server Details ``` diff --git a/internal/api/handlers/v0/publish_test.go b/internal/api/handlers/v0/publish_test.go index 641e730a..28e0d17f 100644 --- a/internal/api/handlers/v0/publish_test.go +++ b/internal/api/handlers/v0/publish_test.go @@ -25,6 +25,11 @@ func (m *MockRegistryService) List(cursor string, limit int) ([]model.Server, st return args.Get(0).([]model.Server), args.String(1), args.Error(2) } +func (m *MockRegistryService) Search(query string, cursor string, limit int) ([]model.Server, string, error) { + args := m.Mock.Called(query, cursor, limit) + return args.Get(0).([]model.Server), args.String(1), args.Error(2) +} + func (m *MockRegistryService) GetByID(id string) (*model.ServerDetail, error) { args := m.Mock.Called(id) return args.Get(0).(*model.ServerDetail), args.Error(1) diff --git a/internal/api/handlers/v0/servers.go b/internal/api/handlers/v0/servers.go index b2fc21f6..804280af 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -68,8 +68,21 @@ func ServersHandler(registry service.RegistryService) http.HandlerFunc { } } - // Use the GetAll method to get paginated results - registries, nextCursor, err := registry.List(cursor, limit) + // Check for search query + searchQuery := r.URL.Query().Get("search") + + var registries []model.Server + var nextCursor string + var err error + + if searchQuery != "" { + // Use search functionality + registries, nextCursor, err = registry.Search(searchQuery, cursor, limit) + } else { + // Use regular list functionality + registries, nextCursor, err = registry.List(cursor, limit) + } + if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/database/database.go b/internal/database/database.go index f2e8a0e2..c9f80b21 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -20,6 +20,8 @@ var ( type Database interface { // List retrieves all MCPRegistry entries with optional filtering List(ctx context.Context, filter map[string]interface{}, cursor string, limit int) ([]*model.Server, string, error) + // Search searches MCPRegistry entries by keyword query + Search(ctx context.Context, query string, cursor string, limit int) ([]*model.Server, string, error) // GetByID retrieves a single ServerDetail by it's ID GetByID(ctx context.Context, id string) (*model.ServerDetail, error) // Publish adds a new ServerDetail to the database diff --git a/internal/database/memory.go b/internal/database/memory.go index fd892a69..892edff4 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -183,6 +183,86 @@ func (db *MemoryDB) List( return result, nextCursor, nil } +// Search searches MCPRegistry entries by keyword query with case-insensitive matching +func (db *MemoryDB) Search(ctx context.Context, query string, cursor string, limit int) ([]*model.Server, string, error) { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + + if limit <= 0 { + limit = 30 + } + + db.mu.RLock() + defer db.mu.RUnlock() + + // Convert query to lowercase for case-insensitive search + searchQuery := strings.ToLower(strings.TrimSpace(query)) + if searchQuery == "" { + // If no search query, fall back to List + return db.List(ctx, nil, cursor, limit) + } + + // Search through all entries + var matchedEntries []*model.Server + for _, entry := range db.entries { + serverCopy := entry.Server + + // Search in name and description (case-insensitive) + if strings.Contains(strings.ToLower(serverCopy.Name), searchQuery) || + strings.Contains(strings.ToLower(serverCopy.Description), searchQuery) { + matchedEntries = append(matchedEntries, &serverCopy) + } + } + + // Sort by relevance (name matches first, then description matches) + sort.Slice(matchedEntries, func(i, j int) bool { + iNameMatch := strings.Contains(strings.ToLower(matchedEntries[i].Name), searchQuery) + jNameMatch := strings.Contains(strings.ToLower(matchedEntries[j].Name), searchQuery) + + if iNameMatch && !jNameMatch { + return true + } + if !iNameMatch && jNameMatch { + return false + } + + // If both or neither match in name, sort by ID for consistency + return matchedEntries[i].ID < matchedEntries[j].ID + }) + + // Apply cursor-based pagination + startIdx := 0 + if cursor != "" { + for i, entry := range matchedEntries { + if entry.ID == cursor { + startIdx = i + 1 + break + } + } + } + + endIdx := startIdx + limit + if endIdx > len(matchedEntries) { + endIdx = len(matchedEntries) + } + + var result []*model.Server + if startIdx < len(matchedEntries) { + result = matchedEntries[startIdx:endIdx] + } else { + result = []*model.Server{} + } + + // Determine next cursor + nextCursor := "" + if endIdx < len(matchedEntries) { + nextCursor = matchedEntries[endIdx-1].ID + } + + return result, nextCursor, nil +} + // GetByID retrieves a single ServerDetail by its ID func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) { if ctx.Err() != nil { diff --git a/internal/database/mongo.go b/internal/database/mongo.go index 3dbf776e..8908c623 100644 --- a/internal/database/mongo.go +++ b/internal/database/mongo.go @@ -5,11 +5,14 @@ import ( "errors" "fmt" "log" + "regexp" + "strings" "time" "github.com/google/uuid" "github.com/modelcontextprotocol/registry/internal/model" "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) @@ -160,6 +163,71 @@ func (db *MongoDB) List( return results, nextCursor, nil } +// Search searches MCPRegistry entries by keyword query using MongoDB regex +func (db *MongoDB) Search(ctx context.Context, query string, cursor string, limit int) ([]*model.Server, string, error) { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + + searchQuery := strings.TrimSpace(query) + if searchQuery == "" { + // If no search query, fall back to List + return db.List(ctx, nil, cursor, limit) + } + + // Use MongoDB regex for case-insensitive search + regexPattern := primitive.Regex{ + Pattern: regexp.QuoteMeta(searchQuery), + Options: "i", // case-insensitive + } + + // Search in name and description fields + mongoFilter := bson.M{ + "$or": []bson.M{ + {"name": regexPattern}, + {"description": regexPattern}, + }, + } + + // Setup pagination options + findOptions := options.Find() + + // Handle cursor pagination + if cursor != "" { + if _, err := uuid.Parse(cursor); err != nil { + return nil, "", fmt.Errorf("invalid cursor format: %w", err) + } + mongoFilter["id"] = bson.M{"$gt": cursor} + } + + // Sort by relevance (name matches first) then by ID for consistency + findOptions.SetSort(bson.M{"id": 1}) + + if limit > 0 { + findOptions.SetLimit(int64(limit)) + } + + // Execute search + mongoCursor, err := db.collection.Find(ctx, mongoFilter, findOptions) + if err != nil { + return nil, "", err + } + defer mongoCursor.Close(ctx) + + var results []*model.Server + if err = mongoCursor.All(ctx, &results); err != nil { + return nil, "", err + } + + // Determine next cursor + nextCursor := "" + if len(results) > 0 && limit > 0 && len(results) >= limit { + nextCursor = results[len(results)-1].ID + } + + return results, nextCursor, nil +} + // GetByID retrieves a single ServerDetail by its ID func (db *MongoDB) GetByID(ctx context.Context, id string) (*model.ServerDetail, error) { if ctx.Err() != nil { diff --git a/internal/service/fake_service.go b/internal/service/fake_service.go index 07aa805d..4c4c3d80 100644 --- a/internal/service/fake_service.go +++ b/internal/service/fake_service.go @@ -98,6 +98,26 @@ func (s *fakeRegistryService) List(cursor string, limit int) ([]model.Server, st return result, nextCursor, nil } +// Search searches registry entries by keyword query +func (s *fakeRegistryService) Search(query string, cursor string, limit int) ([]model.Server, string, error) { + // Create a timeout context for the database operation + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use the database's Search method + entries, nextCursor, err := s.db.Search(ctx, query, cursor, limit) + if err != nil { + return nil, "", err + } + // Convert from []*model.Server to []model.Server + result := make([]model.Server, len(entries)) + for i, entry := range entries { + result[i] = *entry + } + + return result, nextCursor, nil +} + // GetByID retrieves a specific server detail by its ID func (s *fakeRegistryService) GetByID(id string) (*model.ServerDetail, error) { // Create a timeout context for the database operation diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index d9798be3..b2002a2e 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -69,6 +69,29 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]model.Server, st return result, nextCursor, nil } +// Search searches registry entries by keyword query +func (s *registryServiceImpl) Search(query string, cursor string, limit int) ([]model.Server, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if limit <= 0 { + limit = 30 + } + + entries, nextCursor, err := s.db.Search(ctx, query, cursor, limit) + if err != nil { + return nil, "", err + } + + // Convert from []*model.Server to []model.Server + result := make([]model.Server, len(entries)) + for i, entry := range entries { + result[i] = *entry + } + + return result, nextCursor, nil +} + // GetByID retrieves a specific server detail by its ID func (s *registryServiceImpl) GetByID(id string) (*model.ServerDetail, error) { // Create a timeout context for the database operation diff --git a/internal/service/service.go b/internal/service/service.go index a3e14019..7911ce3b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -5,6 +5,7 @@ import "github.com/modelcontextprotocol/registry/internal/model" // RegistryService defines the interface for registry operations type RegistryService interface { List(cursor string, limit int) ([]model.Server, string, error) + Search(query string, cursor string, limit int) ([]model.Server, string, error) GetByID(id string) (*model.ServerDetail, error) Publish(serverDetail *model.ServerDetail) error }