From 30c37ef086cde3b458f357445b3cf4330accae75 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Mon, 8 Sep 2025 20:27:46 +0000 Subject: [PATCH 1/2] Add filtering functionality to server list endpoint This adds support for updated_since, search, and version query parameters to the GET /v0/servers endpoint, enabling: - Incremental sync via updated_since timestamp filtering - Simple substring search on server names - Latest version filtering via version=latest Fixes #291 Fixes #135 :house: Remote-Dev: homespace --- docs/reference/api/official-registry-api.md | 13 ++ internal/api/handlers/v0/servers.go | 43 ++++++- internal/api/handlers/v0/servers_test.go | 136 +++++++++++++++++++- internal/database/database.go | 9 +- internal/database/memory.go | 39 ++++++ internal/database/postgres.go | 20 +++ internal/service/registry_service.go | 8 +- internal/service/service.go | 5 +- 8 files changed, 260 insertions(+), 13 deletions(-) diff --git a/docs/reference/api/official-registry-api.md b/docs/reference/api/official-registry-api.md index c4823972..f8c70447 100644 --- a/docs/reference/api/official-registry-api.md +++ b/docs/reference/api/official-registry-api.md @@ -33,6 +33,19 @@ See [Publisher Commands](../cli/commands.md) for authentication setup. The official registry enforces additional [package validation requirements](../server-json/official-registry-requirements.md) when publishing. +### Server List Filtering + +The official registry extends the `GET /v0/servers` endpoint with additional query parameters for improved discovery and synchronization: + +- `updated_since` - Filter servers updated after RFC3339 timestamp (e.g., `2025-08-07T13:15:04.280Z`) +- `search` - Case-insensitive substring search on server names (e.g., `filesystem`) + - This is intentionally simple. For more advanced searching and filtering, use a subregistry. +- `version` - Filter by version (currently supports `latest` for latest versions only) + +These extensions enable efficient incremental synchronization for downstream registries and improved server discovery. Parameters can be combined and work with standard cursor-based pagination. + +Example: `GET /v0/servers?search=filesystem&updated_since=2025-08-01T00:00:00Z&version=latest` + ### Additional endpoints #### Auth endpoints diff --git a/internal/api/handlers/v0/servers.go b/internal/api/handlers/v0/servers.go index 0cb50e28..12a1bde6 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -3,9 +3,11 @@ package v0 import ( "context" "net/http" + "time" "github.com/danielgtaylor/huma/v2" "github.com/google/uuid" + "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) @@ -19,8 +21,11 @@ type Metadata struct { // ListServersInput represents the input for listing servers type ListServersInput struct { - Cursor string `query:"cursor" doc:"Pagination cursor (UUID)" format:"uuid" required:"false"` - Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100"` + Cursor string `query:"cursor" doc:"Pagination cursor (UUID)" format:"uuid" required:"false" example:"550e8400-e29b-41d4-a716-446655440000"` + Limit int `query:"limit" doc:"Number of items per page" default:"30" minimum:"1" maximum:"100" example:"50"` + UpdatedSince string `query:"updated_since" doc:"Filter servers updated since timestamp (RFC3339 datetime)" required:"false" example:"2025-08-07T13:15:04.280Z"` + Search string `query:"search" doc:"Search servers by name (substring match)" required:"false" example:"filesystem"` + Version string `query:"version" doc:"Filter by version ('latest' for latest version, or an exact version like '1.2.3')" required:"false" example:"latest"` } // ListServersBody represents the paginated server list response body @@ -53,8 +58,38 @@ func RegisterServersEndpoints(api huma.API, registry service.RegistryService) { } } - // Get paginated results - servers, nextCursor, err := registry.List(input.Cursor, input.Limit) + // Build filter from input parameters + filter := &database.ServerFilter{} + + // Parse updated_since parameter + if input.UpdatedSince != "" { + // Parse RFC3339 format + if updatedTime, err := time.Parse(time.RFC3339, input.UpdatedSince); err == nil { + filter.UpdatedSince = &updatedTime + } else { + return nil, huma.Error400BadRequest("Invalid updated_since format: expected RFC3339 timestamp (e.g., 2025-08-07T13:15:04.280Z)") + } + } + + // Handle search parameter + if input.Search != "" { + filter.SubstringName = &input.Search + } + + // Handle version parameter + if input.Version != "" { + if input.Version == "latest" { + // Special case: filter for latest versions + isLatest := true + filter.IsLatest = &isLatest + } else { + // Future: exact version matching + filter.Version = &input.Version + } + } + + // Get paginated results with filtering + servers, nextCursor, err := registry.List(filter, input.Cursor, input.Limit) if err != nil { return nil, huma.Error500InternalServerError("Failed to get registry list", err) } diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index 8ca1923e..b1328111 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -127,6 +127,122 @@ func TestServersListEndpoint(t *testing.T) { expectedStatus: http.StatusOK, expectedServers: []apiv0.ServerJSON{}, }, + { + name: "successful search by name substring", + queryParams: "?search=test-server", + setupRegistryService: func(registry service.RegistryService) { + server1 := apiv0.ServerJSON{ + Name: "com.example/test-server-matching", + Description: "Matching test server", + Repository: model.Repository{ + URL: "https://github.com/example/test-matching", + Source: "github", + ID: "example/test-matching", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + server2 := apiv0.ServerJSON{ + Name: "com.example/other-server", + Description: "Non-matching server", + Repository: model.Repository{ + URL: "https://github.com/example/other", + Source: "github", + ID: "example/other", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + _, _ = registry.Publish(server1) + _, _ = registry.Publish(server2) + }, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will verify in test that only matching server is returned + }, + { + name: "successful updated_since filter with RFC3339", + queryParams: "?updated_since=2020-01-01T00:00:00Z", + setupRegistryService: func(registry service.RegistryService) { + server := apiv0.ServerJSON{ + Name: "com.example/recent-server", + Description: "Recently updated server", + Repository: model.Repository{ + URL: "https://github.com/example/recent", + Source: "github", + ID: "example/recent", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + _, _ = registry.Publish(server) + }, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will verify server is returned since it was updated after 2020 + }, + { + name: "successful version=latest filter", + queryParams: "?version=latest", + setupRegistryService: func(registry service.RegistryService) { + server1 := apiv0.ServerJSON{ + Name: "com.example/versioned-server", + Description: "First version", + Repository: model.Repository{ + URL: "https://github.com/example/versioned", + Source: "github", + ID: "example/versioned", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + server2 := apiv0.ServerJSON{ + Name: "com.example/versioned-server", + Description: "Second version (latest)", + Repository: model.Repository{ + URL: "https://github.com/example/versioned", + Source: "github", + ID: "example/versioned", + }, + VersionDetail: model.VersionDetail{Version: "2.0.0"}, + } + _, _ = registry.Publish(server1) + _, _ = registry.Publish(server2) // This will be marked as latest + }, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will verify only latest server is returned + }, + { + name: "combined search and updated_since filter", + queryParams: "?search=combined&updated_since=2020-01-01T00:00:00Z", + setupRegistryService: func(registry service.RegistryService) { + server1 := apiv0.ServerJSON{ + Name: "com.example/combined-test", + Description: "Server with combined filtering", + Repository: model.Repository{ + URL: "https://github.com/example/combined", + Source: "github", + ID: "example/combined", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + server2 := apiv0.ServerJSON{ + Name: "com.example/other-server", + Description: "Server that doesn't match search", + Repository: model.Repository{ + URL: "https://github.com/example/nomatch", + Source: "github", + ID: "example/nomatch", + }, + VersionDetail: model.VersionDetail{Version: "1.0.0"}, + } + _, _ = registry.Publish(server1) + _, _ = registry.Publish(server2) + }, + expectedStatus: http.StatusOK, + expectedServers: nil, // Will verify only matching server is returned + }, + { + name: "invalid updated_since format", + queryParams: "?updated_since=invalid-timestamp", + setupRegistryService: func(_ service.RegistryService) {}, + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid updated_since format: expected RFC3339", + }, } for _, tc := range testCases { @@ -170,7 +286,25 @@ func TestServersListEndpoint(t *testing.T) { assert.Equal(t, tc.expectedServers, resp.Servers) } else { // For tests with dynamic data, check structure and count - assert.NotEmpty(t, resp.Servers, "Expected at least one server") + // Special handling for filter test cases + switch tc.name { + case "successful search by name substring": + assert.Len(t, resp.Servers, 1, "Expected exactly one matching server") + assert.Contains(t, resp.Servers[0].Name, "test-server", "Server name should contain search term") + case "successful updated_since filter with RFC3339": + assert.Len(t, resp.Servers, 1, "Expected one server updated after 2020") + assert.Contains(t, resp.Servers[0].Name, "recent-server") + case "successful version=latest filter": + assert.Len(t, resp.Servers, 1, "Expected one latest server") + assert.Contains(t, resp.Servers[0].Description, "latest") + case "combined search and updated_since filter": + assert.Len(t, resp.Servers, 1, "Expected one server matching both filters") + assert.Contains(t, resp.Servers[0].Name, "combined", "Server name should contain search term") + default: + assert.NotEmpty(t, resp.Servers, "Expected at least one server") + } + + // General structure validation for _, server := range resp.Servers { assert.NotEmpty(t, server.Name) assert.NotEmpty(t, server.Description) diff --git a/internal/database/database.go b/internal/database/database.go index ac1d519f..0f9dc88a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,6 +3,7 @@ package database import ( "context" "errors" + "time" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) @@ -19,8 +20,12 @@ var ( // ServerFilter defines filtering options for server queries type ServerFilter struct { - Name *string // for finding versions of same server - RemoteURL *string // for duplicate URL detection + Name *string // for finding versions of same server + RemoteURL *string // for duplicate URL detection + UpdatedSince *time.Time // for incremental sync filtering + SubstringName *string // for substring search on name + Version *string // for exact version matching + IsLatest *bool // for filtering latest versions only } // Database defines the interface for database operations diff --git a/internal/database/memory.go b/internal/database/memory.go index bfa9fa07..21271ba6 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "sync" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" @@ -188,6 +189,44 @@ func (db *MemoryDB) matchesFilter(entry *apiv0.ServerJSON, filter *ServerFilter) } } + // Check updatedSince filter + if filter.UpdatedSince != nil { + if entry.Meta == nil || entry.Meta.Official == nil { + return false + } + if entry.Meta.Official.UpdatedAt.Before(*filter.UpdatedSince) || + entry.Meta.Official.UpdatedAt.Equal(*filter.UpdatedSince) { + return false + } + } + + // Check name search filter (substring match) + if filter.SubstringName != nil { + // Case-insensitive substring search + searchLower := strings.ToLower(*filter.SubstringName) + nameLower := strings.ToLower(entry.Name) + if !strings.Contains(nameLower, searchLower) { + return false + } + } + + // Check exact version filter + if filter.Version != nil { + if entry.VersionDetail.Version != *filter.Version { + return false + } + } + + // Check is_latest filter + if filter.IsLatest != nil { + if entry.Meta == nil || entry.Meta.Official == nil { + return false + } + if entry.Meta.Official.IsLatest != *filter.IsLatest { + return false + } + } + return true } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 0f1ae84e..31c3a245 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -71,6 +71,26 @@ func (db *PostgreSQL) List( args = append(args, *filter.RemoteURL) argIndex++ } + if filter.UpdatedSince != nil { + whereConditions = append(whereConditions, fmt.Sprintf("(value->'_meta'->'io.modelcontextprotocol.registry/official'->>'updated_at')::timestamp > $%d", argIndex)) + args = append(args, *filter.UpdatedSince) + argIndex++ + } + if filter.SubstringName != nil { + whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' ILIKE $%d", argIndex)) + args = append(args, "%"+*filter.SubstringName+"%") + argIndex++ + } + if filter.Version != nil { + whereConditions = append(whereConditions, fmt.Sprintf("(value->'version_detail'->>'version') = $%d", argIndex)) + args = append(args, *filter.Version) + argIndex++ + } + if filter.IsLatest != nil { + whereConditions = append(whereConditions, fmt.Sprintf("(value->'_meta'->'io.modelcontextprotocol.registry/official'->>'is_latest')::boolean = $%d", argIndex)) + args = append(args, *filter.IsLatest) + argIndex++ + } } // Add cursor pagination using registry metadata ID diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index 85dafe58..6dbf1c15 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -29,8 +29,8 @@ func NewRegistryService(db database.Database, cfg *config.Config) RegistryServic } } -// List returns registry entries with cursor-based pagination in flattened format -func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) { +// List returns registry entries with cursor-based pagination and optional filtering +func (s *registryServiceImpl) List(filter *database.ServerFilter, cursor string, limit int) ([]apiv0.ServerJSON, string, error) { // Create a timeout context for the database operation ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -40,8 +40,8 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerJSON limit = 30 } - // Use the database's ListServers method with pagination - serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit) + // Use the database's ListServers method with pagination and filtering + serverRecords, nextCursor, err := s.db.List(ctx, filter, cursor, limit) if err != nil { return nil, "", err } diff --git a/internal/service/service.go b/internal/service/service.go index 0aafd289..fd6ebfdb 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,13 +1,14 @@ package service import ( + "github.com/modelcontextprotocol/registry/internal/database" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" ) // RegistryService defines the interface for registry operations type RegistryService interface { - // Retrieve all servers - List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) + // Retrieve all servers with optional filtering + List(filter *database.ServerFilter, cursor string, limit int) ([]apiv0.ServerJSON, string, error) // Retrieve a single server by registry metadata ID GetByID(id string) (*apiv0.ServerJSON, error) // Publish a server From e8628a85d2d79a01ef06dbfeb322c2f3fd2cd767 Mon Sep 17 00:00:00 2001 From: Adam Jones Date: Mon, 8 Sep 2025 20:34:08 +0000 Subject: [PATCH 2/2] Add nolint comments for cyclop warnings on filter functions :house: Remote-Dev: homespace --- internal/database/memory.go | 1 + internal/database/postgres.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/database/memory.go b/internal/database/memory.go index 21271ba6..30dc4385 100644 --- a/internal/database/memory.go +++ b/internal/database/memory.go @@ -165,6 +165,7 @@ func (db *MemoryDB) filterAndSort(allEntries []*apiv0.ServerJSON, filter *Server } // matchesFilter checks if an entry matches the provided filter +//nolint:cyclop // Filter matching logic is inherently complex but clear func (db *MemoryDB) matchesFilter(entry *apiv0.ServerJSON, filter *ServerFilter) bool { if filter == nil { return true diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 31c3a245..4a5c26b6 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -40,6 +40,7 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro }, nil } +//nolint:cyclop // Database filtering logic is inherently complex but clear func (db *PostgreSQL) List( ctx context.Context, filter *ServerFilter,