Skip to content

feat: add keyword search functionality to registry service #142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

```
5 changes: 5 additions & 0 deletions internal/api/handlers/v0/publish_test.go
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 15 additions & 2 deletions internal/api/handlers/v0/servers.go
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions internal/database/memory.go
Original file line number Diff line number Diff line change
@@ -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 {
68 changes: 68 additions & 0 deletions internal/database/mongo.go
Original file line number Diff line number Diff line change
@@ -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 {
20 changes: 20 additions & 0 deletions internal/service/fake_service.go
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions internal/service/registry_service.go
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions internal/service/service.go
Original file line number Diff line number Diff line change
@@ -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
}