diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 4a5ba691e..86c1ac743 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -61,6 +61,25 @@ paths: schema: type: string example: "1.2.3" + - name: filter + in: query + description: | + Comma-separated list of distribution type filters combined with OR logic. Returns servers that match any of the specified filter values. + + Reserved values: + - `sse` - Servers with SSE remote transport + - `streamable-http` - Servers with streamable-http remote transport + - `npm` - Servers with npm packages + - `pypi` - Servers with PyPI packages + - `oci` - Servers with OCI packages + - `nuget` - Servers with NuGet packages + - `mcpb` - Servers with MCPB packages + + Sub-registries MAY extend with vendor-prefixed values (e.g., `x-myvendor-custom`). + required: false + schema: + type: string + example: "npm,pypi" responses: '200': description: A list of MCP servers diff --git a/internal/api/handlers/v0/servers.go b/internal/api/handlers/v0/servers.go index f9f7ba5f7..5feb85fff 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -3,6 +3,7 @@ package v0 import ( "context" "errors" + "fmt" "net/http" "net/url" "strings" @@ -12,6 +13,7 @@ import ( "github.com/modelcontextprotocol/registry/internal/database" "github.com/modelcontextprotocol/registry/internal/service" apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" ) const errRecordNotFound = "record not found" @@ -23,6 +25,7 @@ type ListServersInput struct { 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"` + Filter string `query:"filter" doc:"Comma-separated list of distribution type filters combined with OR logic. Reserved values: sse, streamable-http, npm, pypi, oci, nuget, mcpb." required:"false" example:"npm,pypi"` } // ServerDetailInput represents the input for getting server details @@ -53,33 +56,9 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. Tags: []string{"servers"}, }, func(ctx context.Context, input *ListServersInput) (*Response[apiv0.ServerListResponse], error) { // 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 - } + filter, err := buildListServersFilter(input) + if err != nil { + return nil, err } // Get paginated results with filtering @@ -186,3 +165,57 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. }, nil }) } + +// buildListServersFilter converts ListServersInput query parameters into a ServerFilter. +func buildListServersFilter(input *ListServersInput) (*database.ServerFilter, error) { + filter := &database.ServerFilter{} + + if input.UpdatedSince != "" { + 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)") + } + } + + if input.Search != "" { + filter.SubstringName = &input.Search + } + + if input.Version != "" { + if input.Version == "latest" { + isLatest := true + filter.IsLatest = &isLatest + } else { + filter.Version = &input.Version + } + } + + if input.Filter != "" { + filterValues, err := parseFilterParam(input.Filter) + if err != nil { + return nil, err + } + filter.FilterValues = filterValues + } + + return filter, nil +} + +// parseFilterParam parses a comma-separated filter string and validates each value. +func parseFilterParam(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + var values []string + for _, part := range parts { + v := strings.TrimSpace(part) + if v == "" { + continue + } + if !model.IsValidReservedFilterValue(v) { + return nil, huma.Error400BadRequest( + fmt.Sprintf("Invalid filter value: %q. Valid values are: %s", v, strings.Join(model.ValidFilterValues(), ", "))) + } + values = append(values, v) + } + return values, nil +} diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index e0d19a031..4fa4dd250 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -392,6 +392,246 @@ func TestGetAllVersionsEndpoint(t *testing.T) { } } +func TestListServersWithFilter(t *testing.T) { + ctx := context.Background() + registryService := service.NewRegistryService(database.NewTestDB(t), &config.Config{EnableRegistryValidation: false}) + + // Setup test data: servers with different distribution types + // Server with SSE remote + _, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/sse-server", + Description: "Server with SSE remote", + Version: "1.0.0", + Remotes: []model.Transport{ + {Type: model.TransportTypeSSE, URL: "https://example.com/sse"}, + }, + }) + require.NoError(t, err) + + // Server with streamable-http remote + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/streamable-server", + Description: "Server with streamable-http remote", + Version: "1.0.0", + Remotes: []model.Transport{ + {Type: model.TransportTypeStreamableHTTP, URL: "https://example.com/mcp"}, + }, + }) + require.NoError(t, err) + + // Server with npm package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/npm-server", + Description: "Server with npm package", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeNPM, + Identifier: "@example/mcp-server", + Version: "1.0.0", + Transport: model.Transport{Type: model.TransportTypeStdio}, + }, + }, + }) + require.NoError(t, err) + + // Server with pypi package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/pypi-server", + Description: "Server with pypi package", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypePyPI, + Identifier: "example-mcp-server", + Version: "1.0.0", + Transport: model.Transport{Type: model.TransportTypeStdio}, + }, + }, + }) + require.NoError(t, err) + + // Server with both remote and package (matches multiple filters) + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/multi-server", + Description: "Server with both SSE and npm", + Version: "1.0.0", + Remotes: []model.Transport{ + {Type: model.TransportTypeSSE, URL: "https://example.com/multi/sse"}, + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeNPM, + Identifier: "@example/multi-server", + Version: "1.0.0", + Transport: model.Transport{Type: model.TransportTypeStdio}, + }, + }, + }) + require.NoError(t, err) + + // Server with no remotes or packages (should not match any filter) + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/bare-server", + Description: "Server with no distribution", + Version: "1.0.0", + }) + require.NoError(t, err) + + // Create API + mux := http.NewServeMux() + api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0")) + v0.RegisterServersEndpoints(api, "/v0", registryService) + + tests := []struct { + name string + queryParams string + expectedStatus int + expectedCount int + expectedError string + expectedServers []string // server names expected in results + }{ + { + name: "filter by sse", + queryParams: "?filter=sse", + expectedStatus: http.StatusOK, + expectedCount: 2, // sse-server + multi-server + expectedServers: []string{ + "com.example/sse-server", + "com.example/multi-server", + }, + }, + { + name: "filter by streamable-http", + queryParams: "?filter=streamable-http", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{ + "com.example/streamable-server", + }, + }, + { + name: "filter by npm", + queryParams: "?filter=npm", + expectedStatus: http.StatusOK, + expectedCount: 2, // npm-server + multi-server + expectedServers: []string{ + "com.example/npm-server", + "com.example/multi-server", + }, + }, + { + name: "filter by pypi", + queryParams: "?filter=pypi", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{ + "com.example/pypi-server", + }, + }, + { + name: "comma-separated filters (npm,pypi) - OR logic", + queryParams: "?filter=npm,pypi", + expectedStatus: http.StatusOK, + expectedCount: 3, // npm-server + pypi-server + multi-server + expectedServers: []string{ + "com.example/npm-server", + "com.example/pypi-server", + "com.example/multi-server", + }, + }, + { + name: "comma-separated filters (sse,npm) - OR logic across types", + queryParams: "?filter=sse,npm", + expectedStatus: http.StatusOK, + expectedCount: 3, // sse-server + npm-server + multi-server (has both) + expectedServers: []string{ + "com.example/sse-server", + "com.example/npm-server", + "com.example/multi-server", + }, + }, + { + name: "invalid filter value", + queryParams: "?filter=invalid", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid filter value", + }, + { + name: "partially invalid filter value", + queryParams: "?filter=npm,invalid", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid filter value", + }, + { + name: "filter combined with search", + queryParams: "?filter=npm&search=multi", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{ + "com.example/multi-server", + }, + }, + { + name: "filter combined with version=latest", + queryParams: "?filter=sse&version=latest", + expectedStatus: http.StatusOK, + expectedCount: 2, + expectedServers: []string{ + "com.example/sse-server", + "com.example/multi-server", + }, + }, + { + name: "filter with spaces around values", + queryParams: "?filter=npm%2C%20pypi", // "npm, pypi" URL-encoded + expectedStatus: http.StatusOK, + expectedCount: 3, + }, + { + name: "filter by oci (no matching servers)", + queryParams: "?filter=oci", + expectedStatus: http.StatusOK, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v0/servers"+tt.queryParams, nil) + w := httptest.NewRecorder() + + mux.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.expectedStatus == http.StatusOK { + var resp apiv0.ServerListResponse + err := json.NewDecoder(w.Body).Decode(&resp) + assert.NoError(t, err) + assert.Len(t, resp.Servers, tt.expectedCount) + assert.Equal(t, tt.expectedCount, resp.Metadata.Count) + + if tt.expectedServers != nil { + actualNames := make([]string, len(resp.Servers)) + for i, s := range resp.Servers { + actualNames[i] = s.Server.Name + } + assert.ElementsMatch(t, tt.expectedServers, actualNames) + } + } else if tt.expectedError != "" { + assert.Contains(t, w.Body.String(), tt.expectedError) + } + }) + } +} + func TestServersEndpointEdgeCases(t *testing.T) { ctx := context.Background() registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig()) diff --git a/internal/database/database.go b/internal/database/database.go index ebae55d7f..bca2d0df0 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -27,6 +27,7 @@ type ServerFilter struct { SubstringName *string // for substring search on name Version *string // for exact version matching IsLatest *bool // for filtering latest versions only + FilterValues []string // for filtering by distribution type (sse, streamable, npm, pypi, oci, nuget, mcpb) } // Database defines the interface for database operations diff --git a/internal/database/postgres.go b/internal/database/postgres.go index b47ef74f2..d5efd131c 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -79,6 +79,87 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro }, nil } +// buildServerFilterConditions builds WHERE conditions from ServerFilter fields. +func buildServerFilterConditions(filter *ServerFilter) ([]string, []any, int) { + var conditions []string + var args []any + argIndex := 1 + + if filter == nil { + return conditions, args, argIndex + } + + if filter.Name != nil { + conditions = append(conditions, fmt.Sprintf("server_name = $%d", argIndex)) + args = append(args, *filter.Name) + argIndex++ + } + if filter.RemoteURL != nil { + conditions = append(conditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex)) + args = append(args, *filter.RemoteURL) + argIndex++ + } + if filter.UpdatedSince != nil { + conditions = append(conditions, fmt.Sprintf("updated_at > $%d", argIndex)) + args = append(args, *filter.UpdatedSince) + argIndex++ + } + if filter.SubstringName != nil { + conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d", argIndex)) + args = append(args, "%"+*filter.SubstringName+"%") + argIndex++ + } + if filter.Version != nil { + conditions = append(conditions, fmt.Sprintf("version = $%d", argIndex)) + args = append(args, *filter.Version) + argIndex++ + } + if filter.IsLatest != nil { + conditions = append(conditions, fmt.Sprintf("is_latest = $%d", argIndex)) + args = append(args, *filter.IsLatest) + argIndex++ + } + if len(filter.FilterValues) > 0 { + condition, newArgs, newArgIndex := buildFilterValuesCondition(filter.FilterValues, argIndex) + if condition != "" { + conditions = append(conditions, condition) + args = append(args, newArgs...) + argIndex = newArgIndex + } + } + + return conditions, args, argIndex +} + +// buildFilterValuesCondition builds a SQL OR condition for distribution type filter values. +// It returns the condition string, any new args to append, and the updated argIndex. +func buildFilterValuesCondition(filterValues []string, argIndex int) (string, []any, int) { + var filterConditions []string + var newArgs []any + // Collect package registry types for batching into a single ANY() check + var packageTypes []string + for _, fv := range filterValues { + switch fv { + case model.TransportTypeSSE, model.TransportTypeStreamableHTTP: + filterConditions = append(filterConditions, + fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS r WHERE r->>'type' = '%s')", fv)) + default: + // Package registry types: npm, pypi, oci, nuget, mcpb + packageTypes = append(packageTypes, fv) + } + } + if len(packageTypes) > 0 { + filterConditions = append(filterConditions, + fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'packages') AS pkg WHERE pkg->>'registryType' = ANY($%d))", argIndex)) + newArgs = append(newArgs, packageTypes) + argIndex++ + } + if len(filterConditions) == 0 { + return "", nil, argIndex + } + return "(" + strings.Join(filterConditions, " OR ") + ")", newArgs, argIndex +} + func (db *PostgreSQL) ListServers( ctx context.Context, tx pgx.Tx, @@ -95,43 +176,7 @@ func (db *PostgreSQL) ListServers( } // Build WHERE clause for filtering using dedicated columns - var whereConditions []string - args := []any{} - argIndex := 1 - - // Add filters using dedicated columns for better performance - if filter != nil { - if filter.Name != nil { - whereConditions = append(whereConditions, fmt.Sprintf("server_name = $%d", argIndex)) - args = append(args, *filter.Name) - argIndex++ - } - if filter.RemoteURL != nil { - whereConditions = append(whereConditions, fmt.Sprintf("EXISTS (SELECT 1 FROM jsonb_array_elements(value->'remotes') AS remote WHERE remote->>'url' = $%d)", argIndex)) - args = append(args, *filter.RemoteURL) - argIndex++ - } - if filter.UpdatedSince != nil { - whereConditions = append(whereConditions, fmt.Sprintf("updated_at > $%d", argIndex)) - args = append(args, *filter.UpdatedSince) - argIndex++ - } - if filter.SubstringName != nil { - whereConditions = append(whereConditions, fmt.Sprintf("server_name ILIKE $%d", argIndex)) - args = append(args, "%"+*filter.SubstringName+"%") - argIndex++ - } - if filter.Version != nil { - whereConditions = append(whereConditions, fmt.Sprintf("version = $%d", argIndex)) - args = append(args, *filter.Version) - argIndex++ - } - if filter.IsLatest != nil { - whereConditions = append(whereConditions, fmt.Sprintf("is_latest = $%d", argIndex)) - args = append(args, *filter.IsLatest) - argIndex++ - } - } + whereConditions, args, argIndex := buildServerFilterConditions(filter) // Add cursor pagination using compound serverName:version cursor if cursor != "" { diff --git a/pkg/model/types.go b/pkg/model/types.go index 8a7443d88..193154f9b 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -102,6 +102,32 @@ type Argument struct { IsRepeated bool `json:"isRepeated,omitempty" doc:"Whether the argument can be repeated multiple times."` } +// validFilterValues is the set of recognized filter values for the list servers +// endpoint, built from existing transport and registry type constants. +var validFilterValues = map[string]bool{ + TransportTypeSSE: true, + TransportTypeStreamableHTTP: true, + RegistryTypeNPM: true, + RegistryTypePyPI: true, + RegistryTypeOCI: true, + RegistryTypeNuGet: true, + RegistryTypeMCPB: true, +} + +// ValidFilterValues returns all reserved filter value strings. +func ValidFilterValues() []string { + values := make([]string, 0, len(validFilterValues)) + for v := range validFilterValues { + values = append(values, v) + } + return values +} + +// IsValidReservedFilterValue checks whether a string is a recognized reserved filter value. +func IsValidReservedFilterValue(v string) bool { + return validFilterValues[v] +} + type Icon struct { Src string `json:"src" required:"true" format:"uri" maxLength:"255" doc:"A standard URI pointing to an icon resource. Must be an HTTPS URL. Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the server or a trusted domain. Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain executable JavaScript." example:"https://example.com/icon.png"` MimeType *string `json:"mimeType,omitempty" enum:"image/png,image/jpeg,image/jpg,image/svg+xml,image/webp" doc:"Optional MIME type override if the source MIME type is missing or generic. Must be one of: image/png, image/jpeg, image/jpg, image/svg+xml, image/webp." example:"image/png"`