Skip to content
Merged
Show file tree
Hide file tree
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
13 changes: 13 additions & 0 deletions docs/reference/api/official-registry-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions internal/api/handlers/v0/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
136 changes: 135 additions & 1 deletion internal/api/handlers/v0/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package database
import (
"context"
"errors"
"time"

apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions internal/database/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sort"
"strings"
"sync"

apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
Expand Down Expand Up @@ -164,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
Expand All @@ -188,6 +190,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
}

Expand Down
21 changes: 21 additions & 0 deletions internal/database/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,6 +72,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
Expand Down
8 changes: 4 additions & 4 deletions internal/service/registry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand Down
5 changes: 3 additions & 2 deletions internal/service/service.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading