Add registry validators (#350)

Fixes #96

## Motivation and Context

See #96

## How Has This Been Tested?

Has some unit and integration tests, still needs proper testing of the
different registry types

## Breaking Changes

Yes, new package uploads will need extra verification

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
This commit is contained in:
adam jones 2025-09-04 22:45:59 +01:00 • committed by GitHub
commit 694a4d8879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1859 additions and 728 deletions

View file

@ -11,7 +11,6 @@ import (
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
@ -67,11 +66,6 @@ func RegisterEditEndpoints(api huma.API, registry service.RegistryService, cfg *
return nil, huma.Error403Forbidden("You do not have edit permissions for this server")
}
// Perform all schema validation
if err := validators.ValidatePublishRequest(input.Body); err != nil {
return nil, huma.Error400BadRequest(err.Error())
}
// Prevent renaming servers
if currentServer.Name != input.Body.Name {
return nil, huma.Error400BadRequest("Cannot rename server")

View file

@ -22,8 +22,8 @@ import (
func TestEditServerEndpoint(t *testing.T) {
// Create registry service and insert a common test server
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), config.NewConfig())
// Publish a test server that will be used across test cases
testServer := apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
@ -43,9 +43,9 @@ func TestEditServerEndpoint(t *testing.T) {
assert.NotNil(t, published)
assert.NotNil(t, published.Meta)
assert.NotNil(t, published.Meta.IOModelContextProtocolRegistry)
testServerID := published.Meta.IOModelContextProtocolRegistry.ID
// Publish a second server for permission testing
otherServer := apiv0.ServerJSON{
Name: "io.github.other/test-server",
@ -65,9 +65,9 @@ func TestEditServerEndpoint(t *testing.T) {
assert.NotNil(t, otherPublished)
assert.NotNil(t, otherPublished.Meta)
assert.NotNil(t, otherPublished.Meta.IOModelContextProtocolRegistry)
otherServerID := otherPublished.Meta.IOModelContextProtocolRegistry.ID
// Publish a deleted server for undelete testing
deletedServer := apiv0.ServerJSON{
Name: "io.github.domdomegg/deleted-server",
@ -87,7 +87,7 @@ func TestEditServerEndpoint(t *testing.T) {
assert.NotNil(t, deletedPublished)
assert.NotNil(t, deletedPublished.Meta)
assert.NotNil(t, deletedPublished.Meta.IOModelContextProtocolRegistry)
deletedServerID := deletedPublished.Meta.IOModelContextProtocolRegistry.ID
testCases := []struct {
@ -143,9 +143,9 @@ func TestEditServerEndpoint(t *testing.T) {
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "invalid token",
@ -155,12 +155,12 @@ func TestEditServerEndpoint(t *testing.T) {
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "permission denied - no edit permissions",
name: "permission denied - no edit permissions",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -182,7 +182,7 @@ func TestEditServerEndpoint(t *testing.T) {
expectedError: "Forbidden",
},
{
name: "permission denied - wrong resource",
name: "permission denied - wrong resource",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -204,7 +204,7 @@ func TestEditServerEndpoint(t *testing.T) {
expectedError: "Forbidden",
},
{
name: "server not found",
name: "server not found",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -226,7 +226,7 @@ func TestEditServerEndpoint(t *testing.T) {
expectedError: "Not Found",
},
{
name: "validation error - invalid server name",
name: "validation error - invalid server name",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -248,7 +248,7 @@ func TestEditServerEndpoint(t *testing.T) {
expectedError: "Bad Request",
},
{
name: "cannot undelete server",
name: "cannot undelete server",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{

View file

@ -9,7 +9,6 @@ import (
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
@ -49,11 +48,6 @@ func RegisterPublishEndpoint(api huma.API, registry service.RegistryService, cfg
return nil, huma.Error401Unauthorized("Invalid or expired Registry JWT token", err)
}
// Perform all schema validation
if err := validators.ValidatePublishRequest(input.Body); err != nil {
return nil, huma.Error400BadRequest(err.Error())
}
// Verify that the token has permission to publish the server
if !jwtManager.HasPermission(input.Body.Name, auth.PermissionActionPublish, claims.Permissions) {
return nil, huma.Error403Forbidden("You do not have permission to publish this server")

View file

@ -36,17 +36,18 @@ func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims)
}
func TestPublishIntegration(t *testing.T) {
// Setup fake service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Create test config with a valid Ed25519 seed
// Create test config with a valid Ed25519 seed and validation disabled for testing
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableRegistryValidation: false, // Disable for integration tests
}
// Setup fake service
registryService := service.NewRegistryService(database.NewMemoryDB(), testConfig)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
@ -219,4 +220,53 @@ func TestPublishIntegration(t *testing.T) {
assert.Equal(t, http.StatusForbidden, rr.Code)
assert.Contains(t, rr.Body.String(), "You do not have permission to publish this server")
})
t.Run("publish succeeds with MCPB package", func(t *testing.T) {
publishReq := apiv0.ServerJSON{
Name: "io.github.domdomegg/airtable-mcp-server",
Description: "A test server with MCPB package",
VersionDetail: model.VersionDetail{
Version: "1.7.2",
},
Status: model.StatusActive,
Packages: []model.Package{
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb",
Version: "1.7.2",
},
},
}
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateIntegrationTestJWTToken(testConfig, claims)
require.NoError(t, err)
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response apiv0.ServerJSON
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.VersionDetail.Version, response.VersionDetail.Version)
assert.Len(t, response.Packages, 1)
assert.Equal(t, model.RegistryTypeMCPB, response.Packages[0].RegistryType)
})
}

View file

@ -0,0 +1,225 @@
package v0_test
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/auth"
"github.com/modelcontextprotocol/registry/internal/config"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublishRegistryValidation(t *testing.T) {
// Create test config with validation ENABLED
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableRegistryValidation: true, // Enable validation for this test
}
// Setup fake service
registryService := service.NewRegistryService(database.NewMemoryDB(), testConfig)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint
v0.RegisterPublishEndpoint(api, registryService, testConfig)
t.Run("publish fails with npm registry validation error", func(t *testing.T) {
publishReq := apiv0.ServerJSON{
Name: "com.example/test-server-with-npm",
Description: "A test server with invalid npm package reference",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeNPM,
Identifier: "nonexistent-npm-package-xyz123",
Version: "1.0.0",
},
},
}
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateTestJWTToken(testConfig, claims)
require.NoError(t, err)
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "registry validation failed")
})
t.Run("publish succeeds with MCPB package (registry validation enabled)", func(t *testing.T) {
publishReq := apiv0.ServerJSON{
Name: "com.example/test-server-mcpb-validation",
Description: "A test server with MCPB package and registry validation enabled",
VersionDetail: model.VersionDetail{
Version: "0.0.36",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
Version: "0.0.36",
},
},
}
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateTestJWTToken(testConfig, claims)
require.NoError(t, err)
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var response apiv0.ServerJSON
err = json.Unmarshal(rr.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, publishReq.Name, response.Name)
assert.Equal(t, publishReq.VersionDetail.Version, response.VersionDetail.Version)
assert.Len(t, response.Packages, 1)
assert.Equal(t, model.RegistryTypeMCPB, response.Packages[0].RegistryType)
})
t.Run("publish fails when second package fails npm validation", func(t *testing.T) {
publishReq := apiv0.ServerJSON{
Name: "com.example/test-server-multiple-packages",
Description: "A test server with multiple packages where second fails",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
Version: "1.0.0",
},
{
RegistryType: model.RegistryTypeNPM,
Identifier: "nonexistent-second-package-abc123",
Version: "1.0.0",
},
},
}
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateTestJWTToken(testConfig, claims)
require.NoError(t, err)
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "registry validation failed for package 1")
assert.Contains(t, rr.Body.String(), "nonexistent-second-package-abc123")
})
t.Run("publish fails when first package fails validation", func(t *testing.T) {
publishReq := apiv0.ServerJSON{
Name: "com.example/test-server-first-package-fails",
Description: "A test server where first package fails",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeNPM,
Identifier: "nonexistent-first-package-xyz789",
Version: "1.0.0",
},
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "https://github.com/microsoft/playwright-mcp/releases/download/v0.0.36/playwright-mcp-extension-v0.0.36.zip",
Version: "1.0.0",
},
},
}
// Generate valid JWT token with wildcard permission
claims := auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
}
token, err := generateTestJWTToken(testConfig, claims)
require.NoError(t, err)
body, err := json.Marshal(publishReq)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/v0/publish", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "registry validation failed for package 0")
assert.Contains(t, rr.Body.String(), "nonexistent-first-package-xyz789")
})
}

View file

@ -24,7 +24,6 @@ import (
"github.com/stretchr/testify/require"
)
// Helper function to generate a valid JWT token for testing
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
jwtManager := auth.NewJWTManager(cfg)
@ -41,17 +40,18 @@ func TestPublishEndpoint(t *testing.T) {
_, err := rand.Read(testSeed)
require.NoError(t, err)
testConfig := &config.Config{
JWTPrivateKey: hex.EncodeToString(testSeed),
JWTPrivateKey: hex.EncodeToString(testSeed),
EnableRegistryValidation: false, // Disable for unit tests
}
testCases := []struct {
name string
requestBody interface{}
tokenClaims *auth.JWTClaims
authHeader string
name string
requestBody interface{}
tokenClaims *auth.JWTClaims
authHeader string
setupRegistryService func(service.RegistryService)
expectedStatus int
expectedError string
expectedStatus int
expectedError string
}{
{
name: "successful publish with GitHub auth",
@ -105,9 +105,9 @@ func TestPublishEndpoint(t *testing.T) {
expectedStatus: http.StatusOK,
},
{
name: "missing authorization header",
requestBody: apiv0.ServerJSON{},
authHeader: "", // Empty auth header
name: "missing authorization header",
requestBody: apiv0.ServerJSON{},
authHeader: "", // Empty auth header
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
@ -121,7 +121,7 @@ func TestPublishEndpoint(t *testing.T) {
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
authHeader: "InvalidFormat",
authHeader: "InvalidFormat",
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
@ -137,7 +137,7 @@ func TestPublishEndpoint(t *testing.T) {
Version: "1.0.0",
},
},
authHeader: "Bearer invalidToken",
authHeader: "Bearer invalidToken",
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
@ -209,13 +209,38 @@ func TestPublishEndpoint(t *testing.T) {
expectedStatus: http.StatusBadRequest,
expectedError: "invalid version: cannot publish duplicate version",
},
{
name: "package validation success - MCPB package",
requestBody: apiv0.ServerJSON{
Name: "com.example/test-server-mcpb",
Description: "A test server with MCPB package",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Packages: []model.Package{
{
RegistryType: model.RegistryTypeMCPB,
Identifier: "https://github.com/example/server/releases/download/v1.0.0/server.tar.gz",
Version: "1.0.0",
},
},
},
tokenClaims: &auth.JWTClaims{
AuthMethod: auth.MethodNone,
Permissions: []auth.Permission{
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusOK,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), testConfig)
// Setup registry service
tc.setupRegistryService(registryService)

View file

@ -11,6 +11,7 @@ import (
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/google/uuid"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
@ -131,7 +132,7 @@ func TestServersListEndpoint(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), config.NewConfig())
tc.setupRegistryService(registryService)
// Create a new test API
@ -202,7 +203,7 @@ func TestServersListEndpoint(t *testing.T) {
func TestServersDetailEndpoint(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), config.NewConfig())
testServer, err := registryService.Publish(apiv0.ServerJSON{
Name: "com.example/test-server",
@ -284,7 +285,7 @@ func TestServersDetailEndpoint(t *testing.T) {
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
func TestServersEndpointsIntegration(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), config.NewConfig())
// Test data - publish a server and get its actual ID
testServer := apiv0.ServerJSON{

View file

@ -21,7 +21,7 @@ import (
)
func TestPrometheusHandler(t *testing.T) {
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
registryService := service.NewRegistryService(database.NewMemoryDB(), config.NewConfig())
server, err := registryService.Publish(apiv0.ServerJSON{
Name: "io.github.example/test-server",
Description: "Test server detail",