diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index a661a870..4a1d7171 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -61,6 +61,18 @@ paths: schema: type: string example: "1.2.3" + - name: type + in: query + description: | + Filter by distribution type. Allows discovering servers based on how they are made available to users. + + Example values include: 'remote', 'npm', 'pypi', 'oci', 'nuget', 'mcpb' + + Note: Servers with multiple package types or both remotes and packages will appear in results for all matching type filters. + required: false + schema: + type: string + example: "remote" 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 f9f7ba5f..db072c63 100644 --- a/internal/api/handlers/v0/servers.go +++ b/internal/api/handlers/v0/servers.go @@ -12,6 +12,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 +24,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"` + Type string `query:"type" doc:"Filter by distribution type: 'remote' for remote transports, or package type ('npm', 'pypi', 'oci', 'nuget', 'mcpb')" required:"false" example:"remote"` } // ServerDetailInput represents the input for getting server details @@ -42,6 +44,8 @@ type ServerVersionsInput struct { } // RegisterServersEndpoints registers all server-related endpoints with a custom path prefix +// +//nolint:cyclop // Function registers multiple endpoints with validation logic func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service.RegistryService) { // List servers endpoint huma.Register(api, huma.Operation{ @@ -82,6 +86,15 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. } } + // Handle type parameter + if input.Type != "" { + // Validate that the type is a valid distribution type + if !model.IsValidDistributionType(input.Type) { + return nil, huma.Error400BadRequest("Invalid type parameter: must be one of 'remote', 'npm', 'pypi', 'oci', 'nuget', or 'mcpb'") + } + filter.ConfigType = &input.Type + } + // Get paginated results with filtering servers, nextCursor, err := registry.ListServers(ctx, filter, input.Cursor, input.Limit) if err != nil { diff --git a/internal/api/handlers/v0/servers_test.go b/internal/api/handlers/v0/servers_test.go index e0d19a03..d8e581fd 100644 --- a/internal/api/handlers/v0/servers_test.go +++ b/internal/api/handlers/v0/servers_test.go @@ -518,3 +518,280 @@ func TestServersEndpointEdgeCases(t *testing.T) { } }) } + +func TestListServersWithTypeFilter(t *testing.T) { + ctx := context.Background() + registryService := service.NewRegistryService(database.NewTestDB(t), config.NewConfig()) + + // Setup test data with different distribution types + + // Server with remote only + _, err := registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/remote-only", + Description: "Server with remote transport only", + Version: "1.0.0", + Remotes: []model.Transport{ + { + Type: "streamable-http", + 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: "npm", + Identifier: "@example/mcp-server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + 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: "pypi", + Identifier: "example-mcp-server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + require.NoError(t, err) + + // Server with OCI package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/oci-server", + Description: "Server with OCI container", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: "oci", + Identifier: "ghcr.io/example/mcp-server:1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + require.NoError(t, err) + + // Server with NuGet package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/nuget-server", + Description: "Server with NuGet package", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: "nuget", + Identifier: "Example.MCP.Server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + require.NoError(t, err) + + // Server with MCPB package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/mcpb-server", + Description: "Server with MCPB package", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: "mcpb", + Identifier: "https://example.com/server.mcpb", + FileSHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + require.NoError(t, err) + + // Server with both remote and package + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/mixed-server", + Description: "Server with both remote and package", + Version: "1.0.0", + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://example.com/sse", + }, + }, + Packages: []model.Package{ + { + RegistryType: "npm", + Identifier: "@example/mixed-server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + require.NoError(t, err) + + // Server with multiple package types + _, err = registryService.CreateServer(ctx, &apiv0.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "com.example/multi-package", + Description: "Server with multiple package types", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: "npm", + Identifier: "@example/multi-server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + { + RegistryType: "pypi", + Identifier: "example-multi-server", + Version: "1.0.0", + Transport: model.Transport{ + Type: "stdio", + }, + }, + }, + }) + 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 + }{ + { + name: "filter by remote type", + queryParams: "?type=remote", + expectedStatus: http.StatusOK, + expectedCount: 2, + expectedServers: []string{"com.example/remote-only", "com.example/mixed-server"}, + }, + { + name: "filter by npm type", + queryParams: "?type=npm", + expectedStatus: http.StatusOK, + expectedCount: 3, + expectedServers: []string{"com.example/npm-server", "com.example/mixed-server", "com.example/multi-package"}, + }, + { + name: "filter by pypi type", + queryParams: "?type=pypi", + expectedStatus: http.StatusOK, + expectedCount: 2, + expectedServers: []string{"com.example/pypi-server", "com.example/multi-package"}, + }, + { + name: "filter by oci type", + queryParams: "?type=oci", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{"com.example/oci-server"}, + }, + { + name: "filter by nuget type", + queryParams: "?type=nuget", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{"com.example/nuget-server"}, + }, + { + name: "filter by mcpb type", + queryParams: "?type=mcpb", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{"com.example/mcpb-server"}, + }, + { + name: "invalid type parameter", + queryParams: "?type=invalid", + expectedStatus: http.StatusBadRequest, + expectedError: "Invalid type parameter", + }, + { + name: "combine type with search", + queryParams: "?type=npm&search=npm", + expectedStatus: http.StatusOK, + expectedCount: 1, + expectedServers: []string{"com.example/npm-server"}, + }, + { + name: "combine type with version", + queryParams: "?type=remote&version=latest", + expectedStatus: http.StatusOK, + expectedCount: 2, + expectedServers: []string{"com.example/remote-only", "com.example/mixed-server"}, + }, + } + + 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) + + // Verify the expected servers are returned + if tt.expectedServers != nil { + returnedServers := make(map[string]bool) + for _, server := range resp.Servers { + returnedServers[server.Server.Name] = true + } + for _, expectedServer := range tt.expectedServers { + assert.True(t, returnedServers[expectedServer], "Expected server %s to be in results", expectedServer) + } + } + } else if tt.expectedError != "" { + assert.Contains(t, w.Body.String(), tt.expectedError) + } + }) + } +} diff --git a/internal/database/database.go b/internal/database/database.go index ebae55d7..e99b52d7 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 + ConfigType *string // for filtering by distribution type: "remote", "npm", "pypi", "oci", "nuget", or "mcpb" } // Database defines the interface for database operations diff --git a/internal/database/postgres.go b/internal/database/postgres.go index b47ef74f..925bda39 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -79,6 +79,7 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro }, nil } +//nolint:cyclop // Function builds complex SQL query with many optional filters func (db *PostgreSQL) ListServers( ctx context.Context, tx pgx.Tx, @@ -131,6 +132,21 @@ func (db *PostgreSQL) ListServers( args = append(args, *filter.IsLatest) argIndex++ } + if filter.ConfigType != nil { + switch *filter.ConfigType { + case string(model.DistributionRemote): + // Check if remotes array has at least one element + whereConditions = append(whereConditions, "jsonb_array_length(COALESCE(value->'remotes', '[]'::jsonb)) > 0") + case string(model.DistributionNpm), string(model.DistributionPypi), string(model.DistributionOci), string(model.DistributionNuget), string(model.DistributionMcpb): + // Check if packages array contains at least one package with the specified registryType + whereConditions = append(whereConditions, fmt.Sprintf( + "EXISTS (SELECT 1 FROM jsonb_array_elements(value->'packages') AS pkg WHERE pkg->>'registryType' = $%d)", + argIndex, + )) + args = append(args, *filter.ConfigType) + argIndex++ + } + } } // Add cursor pagination using compound serverName:version cursor diff --git a/pkg/model/types.go b/pkg/model/types.go index 454e2255..874f9425 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -8,6 +8,39 @@ const ( StatusDeleted Status = "deleted" ) +type ServerDistributionType string + +const ( + DistributionRemote ServerDistributionType = "remote" + DistributionNpm ServerDistributionType = "npm" + DistributionPypi ServerDistributionType = "pypi" + DistributionOci ServerDistributionType = "oci" + DistributionNuget ServerDistributionType = "nuget" + DistributionMcpb ServerDistributionType = "mcpb" +) + +// ValidDistributionTypes returns all valid distribution type values +func ValidDistributionTypes() []ServerDistributionType { + return []ServerDistributionType{ + DistributionRemote, + DistributionNpm, + DistributionPypi, + DistributionOci, + DistributionNuget, + DistributionMcpb, + } +} + +// IsValidDistributionType checks if a string is a valid distribution type +func IsValidDistributionType(s string) bool { + switch ServerDistributionType(s) { + case DistributionRemote, DistributionNpm, DistributionPypi, DistributionOci, DistributionNuget, DistributionMcpb: + return true + default: + return false + } +} + type Transport struct { Type string `json:"type" doc:"Transport type (stdio, streamable-http, or sse)" example:"stdio"` URL string `json:"url,omitempty" doc:"URL for streamable-http or sse transports" example:"https://api.example.com/mcp"`