Database implementation cleanup (#335)

## Motivation and Context

The database logic was getting a bit unwieldy and fragile - especially
because business logic that should really have lived at the registry
service level was being pushed into the persistence level.

This PR tightens the interface, simplifies the code there, as well as
rips out a bunch of mocks etc. we don't need.

## How Has This Been Tested?

Locally everything works

## Breaking Changes

The list endpoint was surprisingly always filtering for isLatest only,
which seems like a bug? I have removed this filter.
This commit is contained in:
adam jones 2025-09-02 13:37:28 +01:00 • committed by GitHub
commit 9d9a02c57f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 669 additions and 1280 deletions

View file

@ -14,7 +14,7 @@ publisher: ## Build the publisher tool
# Test targets
test-unit: ## Run unit tests with coverage
go test -v -race -coverprofile=coverage.out -covermode=atomic ./internal/...
go test -v -race -coverprofile=coverage.out -covermode=atomic -coverpkg=./internal/... ./internal/...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"

View file

@ -14,9 +14,9 @@ import (
"github.com/modelcontextprotocol/registry/internal/api"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/importer"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/modelcontextprotocol/registry/internal/telemetry"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
func main() {
@ -46,7 +46,7 @@ func main() {
// Initialize services based on environment
switch cfg.DatabaseType {
case config.DatabaseTypeMemory:
db = database.NewMemoryDB(map[string]*apiv0.ServerJSON{})
db = database.NewMemoryDB()
registryService = service.NewRegistryServiceWithDB(db)
case config.DatabaseTypePostgreSQL:
// Use PostgreSQL for real registry service
@ -84,7 +84,8 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := db.ImportSeed(ctx, cfg.SeedFrom); err != nil {
importerService := importer.NewService(db)
if err := importerService.ImportFromPath(ctx, cfg.SeedFrom); err != nil {
log.Printf("Failed to import seed data: %v", err)
} else {
log.Println("Data import completed successfully")

1
go.mod
View file

@ -38,7 +38,6 @@ require (
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/otlptranslator v0.0.0-20250717125610-8549f4ab4f8f // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/crypto v0.41.0 // indirect

View file

@ -3,7 +3,6 @@ package v0_test
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@ -11,29 +10,96 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
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"
)
func TestEditServerEndpoint(t *testing.T) {
// Create registry service and insert a common test server
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Publish a test server that will be used across test cases
testServer := apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original test server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/domdomegg/test-server",
Source: "github",
ID: "domdomegg/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
published, err := registryService.Publish(testServer)
assert.NoError(t, err)
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",
Description: "Other test server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/other/test-server",
Source: "github",
ID: "other/test-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
otherPublished, err := registryService.Publish(otherServer)
assert.NoError(t, err)
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",
Description: "Deleted test server",
Status: model.StatusDeleted,
Repository: model.Repository{
URL: "https://github.com/domdomegg/deleted-server",
Source: "github",
ID: "domdomegg/deleted-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
deletedPublished, err := registryService.Publish(deletedServer)
assert.NoError(t, err)
assert.NotNil(t, deletedPublished)
assert.NotNil(t, deletedPublished.Meta)
assert.NotNil(t, deletedPublished.Meta.IOModelContextProtocolRegistry)
deletedServerID := deletedPublished.Meta.IOModelContextProtocolRegistry.ID
testCases := []struct {
name string
serverID string
authHeader string
requestBody interface{}
setupMocks func(*MockRegistryService)
serverID string
expectedStatus int
expectedError string
}{
{
name: "successful edit with valid token and permissions",
serverID: "550e8400-e29b-41d4-a716-446655440001",
name: "successful edit with valid token and permissions",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -58,62 +124,43 @@ func TestEditServerEndpoint(t *testing.T) {
Version: "1.0.1",
},
},
setupMocks: func(registry *MockRegistryService) {
// Current server (not deleted)
currentServer := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original server",
Status: model.StatusActive,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
expectedResponse := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
Status: model.StatusDeprecated,
}
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(expectedResponse, nil)
},
serverID: testServerID,
expectedStatus: http.StatusOK,
},
{
name: "missing authorization header",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: "",
requestBody: apiv0.ServerJSON{},
setupMocks: func(_ *MockRegistryService) {},
serverID: testServerID,
expectedStatus: 422,
expectedError: "required header parameter is missing",
},
{
name: "invalid authorization header format",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: "InvalidFormat token123",
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "invalid token",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: "Bearer invalid-token",
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
serverID: testServerID,
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "permission denied - no edit permissions",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -126,24 +173,16 @@ func TestEditServerEndpoint(t *testing.T) {
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
},
setupMocks: func(registry *MockRegistryService) {
// Need to mock GetByID since we check permissions against existing server name
currentServer := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original server",
Status: model.StatusActive,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: testServerID,
expectedStatus: http.StatusForbidden,
expectedError: "Forbidden",
},
{
name: "permission denied - wrong resource",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -156,25 +195,16 @@ func TestEditServerEndpoint(t *testing.T) {
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.other/test-server",
Description: "Updated test server",
},
setupMocks: func(registry *MockRegistryService) {
// Need to mock GetByID since we check permissions against existing server name
// This test case shows a different scenario: existing server is "other" but user only has perms for "domdomegg"
currentServer := &apiv0.ServerJSON{
Name: "io.github.other/test-server",
Description: "Original server",
Status: model.StatusActive,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
Name: "io.github.other/test-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: otherServerID,
expectedStatus: http.StatusForbidden,
expectedError: "Forbidden",
},
{
name: "server not found",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -187,49 +217,16 @@ func TestEditServerEndpoint(t *testing.T) {
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
},
setupMocks: func(registry *MockRegistryService) {
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(nil, database.ErrNotFound)
Name: "io.github.domdomegg/nonexistent-server",
Description: "Updated test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
serverID: "550e8400-e29b-41d4-a716-446655440999", // Non-existent ID
expectedStatus: http.StatusNotFound,
expectedError: "Not Found",
},
{
name: "service error",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
AuthMethod: auth.MethodGitHubAT,
AuthMethodSubject: "domdomegg",
Permissions: []auth.Permission{
{Action: auth.PermissionActionEdit, ResourcePattern: "*"},
},
})
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Updated test server",
},
setupMocks: func(registry *MockRegistryService) {
// Current server (not deleted)
currentServer := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original server",
Status: model.StatusActive,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
registry.On("EditServer", "550e8400-e29b-41d4-a716-446655440001", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("database error"))
},
expectedStatus: http.StatusBadRequest,
expectedError: "Bad Request",
},
{
name: "validation error - invalid server name",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -246,20 +243,12 @@ func TestEditServerEndpoint(t *testing.T) {
Description: "Test server",
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
setupMocks: func(registry *MockRegistryService) {
currentServer := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original server",
Status: model.StatusActive,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
},
serverID: testServerID,
expectedStatus: http.StatusBadRequest,
expectedError: "Bad Request",
},
{
name: "cannot undelete server",
serverID: "550e8400-e29b-41d4-a716-446655440001",
authHeader: func() string {
cfg := &config.Config{JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c"}
token, _ := generateTestJWTToken(cfg, auth.JWTClaims{
@ -272,19 +261,19 @@ func TestEditServerEndpoint(t *testing.T) {
return "Bearer " + token
}(),
requestBody: apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Name: "io.github.domdomegg/deleted-server",
Description: "Trying to undelete server",
Status: model.StatusActive,
Repository: model.Repository{
URL: "https://github.com/domdomegg/deleted-server",
Source: "github",
ID: "domdomegg/deleted-server",
},
VersionDetail: model.VersionDetail{
Version: "1.0.1",
},
},
setupMocks: func(registry *MockRegistryService) {
// Current server is deleted
currentServer := &apiv0.ServerJSON{
Name: "io.github.domdomegg/test-server",
Description: "Original server",
Status: model.StatusDeleted,
}
registry.On("GetByID", "550e8400-e29b-41d4-a716-446655440001").Return(currentServer, nil)
},
serverID: deletedServerID,
expectedStatus: http.StatusBadRequest,
expectedError: "Cannot change status of deleted server",
},
@ -292,10 +281,6 @@ func TestEditServerEndpoint(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry
mockRegistry := new(MockRegistryService)
tc.setupMocks(mockRegistry)
// Create Huma API
mux := http.NewServeMux()
humaConfig := huma.DefaultConfig("Test API", "1.0.0")
@ -305,7 +290,7 @@ func TestEditServerEndpoint(t *testing.T) {
cfg := &config.Config{
JWTPrivateKey: "bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c",
}
v0.RegisterEditEndpoints(api, mockRegistry, cfg)
v0.RegisterEditEndpoints(api, registryService, cfg)
// Create request body
var requestBody []byte
@ -338,7 +323,7 @@ func TestEditServerEndpoint(t *testing.T) {
assert.Contains(t, w.Body.String(), tc.expectedError)
}
mockRegistry.AssertExpectations(t)
// No mock assertions needed with real service
})
}
}

View file

@ -16,6 +16,7 @@ import (
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"
@ -36,7 +37,7 @@ func generateIntegrationTestJWTToken(cfg *config.Config, claims auth.JWTClaims)
func TestPublishIntegration(t *testing.T) {
// Setup fake service
registryService := service.NewFakeRegistryService()
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Create test config with a valid Ed25519 seed
testSeed := make([]byte, ed25519.SeedSize)

View file

@ -7,7 +7,6 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@ -17,46 +16,14 @@ import (
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/mock"
"github.com/stretchr/testify/require"
)
// MockRegistryService is a mock implementation of the RegistryService interface
type MockRegistryService struct {
mock.Mock
}
func (m *MockRegistryService) List(cursor string, limit int) ([]apiv0.ServerJSON, string, error) {
args := m.Called(cursor, limit)
return args.Get(0).([]apiv0.ServerJSON), args.String(1), args.Error(2)
}
func (m *MockRegistryService) GetByID(id string) (*apiv0.ServerJSON, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
}
func (m *MockRegistryService) Publish(request apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
args := m.Called(request)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
}
func (m *MockRegistryService) EditServer(id string, request apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
args := m.Called(id, request)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*apiv0.ServerJSON), args.Error(1)
}
// Helper function to generate a valid JWT token for testing
func generateTestJWTToken(cfg *config.Config, claims auth.JWTClaims) (string, error) {
@ -82,7 +49,7 @@ func TestPublishEndpoint(t *testing.T) {
requestBody interface{}
tokenClaims *auth.JWTClaims
authHeader string
setupMocks func(*MockRegistryService)
setupRegistryService func(service.RegistryService)
expectedStatus int
expectedError string
}{
@ -107,8 +74,8 @@ func TestPublishEndpoint(t *testing.T) {
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil)
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusOK,
},
@ -132,8 +99,8 @@ func TestPublishEndpoint(t *testing.T) {
{Action: auth.PermissionActionPublish, ResourcePattern: "example/*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(&apiv0.ServerJSON{}, nil)
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusOK,
},
@ -141,7 +108,9 @@ func TestPublishEndpoint(t *testing.T) {
name: "missing authorization header",
requestBody: apiv0.ServerJSON{},
authHeader: "", // Empty auth header
setupMocks: func(_ *MockRegistryService) {},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "required header parameter is missing",
},
@ -153,7 +122,9 @@ func TestPublishEndpoint(t *testing.T) {
VersionDetail: model.VersionDetail{Version: "1.0.0"},
},
authHeader: "InvalidFormat",
setupMocks: func(_ *MockRegistryService) {},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid Authorization header format",
},
@ -167,7 +138,9 @@ func TestPublishEndpoint(t *testing.T) {
},
},
authHeader: "Bearer invalidToken",
setupMocks: func(_ *MockRegistryService) {},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusUnauthorized,
expectedError: "Invalid or expired Registry JWT token",
},
@ -191,7 +164,9 @@ func TestPublishEndpoint(t *testing.T) {
{Action: auth.PermissionActionPublish, ResourcePattern: "io.github.example/*"},
},
},
setupMocks: func(_ *MockRegistryService) {},
setupRegistryService: func(_ service.RegistryService) {
// Empty registry - no setup needed
},
expectedStatus: http.StatusForbidden,
expectedError: "You do not have permission to publish this server",
},
@ -215,28 +190,41 @@ func TestPublishEndpoint(t *testing.T) {
{Action: auth.PermissionActionPublish, ResourcePattern: "*"},
},
},
setupMocks: func(registry *MockRegistryService) {
registry.On("Publish", mock.AnythingOfType("v0.ServerJSON")).Return(nil, errors.New("cannot publish duplicate version"))
setupRegistryService: func(registry service.RegistryService) {
// Pre-publish the same server to cause duplicate version error
existingServer := apiv0.ServerJSON{
Name: "example/test-server",
Description: "Existing test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Repository: model.Repository{
URL: "https://github.com/example/test-server-existing",
Source: "github",
ID: "example/test-server-existing",
},
}
_, _ = registry.Publish(existingServer)
},
expectedStatus: http.StatusBadRequest,
expectedError: "Failed to publish server",
expectedError: "invalid version: cannot publish duplicate version",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mocks
mockRegistry := new(MockRegistryService)
// Setup mocks
tc.setupMocks(mockRegistry)
// Create registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Setup registry service
tc.setupRegistryService(registryService)
// Create a new ServeMux and Huma API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the endpoint with test config
v0.RegisterPublishEndpoint(api, mockRegistry, testConfig)
v0.RegisterPublishEndpoint(api, registryService, testConfig)
// Prepare request body
var requestBody []byte
@ -272,8 +260,7 @@ func TestPublishEndpoint(t *testing.T) {
assert.Contains(t, rr.Body.String(), tc.expectedError)
}
// Verify mock expectations
mockRegistry.AssertExpectations(t)
// No mock expectations to verify
})
}
}

View file

@ -3,7 +3,6 @@ package v0_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@ -12,67 +11,29 @@ 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/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/mock"
)
func TestServersListEndpoint(t *testing.T) {
testCases := []struct {
name string
queryParams string
setupMocks func(*MockRegistryService)
expectedStatus int
expectedServers []apiv0.ServerJSON
expectedMeta *v0.Metadata
expectedError string
name string
queryParams string
setupRegistryService func(service.RegistryService)
expectedStatus int
expectedServers []apiv0.ServerJSON
expectedMeta *v0.Metadata
expectedError string
}{
{
name: "successful list with default parameters",
setupMocks: func(registry *MockRegistryService) {
servers := []apiv0.ServerJSON{
{
Name: "test-server-1",
Description: "First test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-1",
Source: "github",
ID: "example/test-server-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440001",
},
},
},
{
Name: "test-server-2",
Description: "Second test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-2",
Source: "github",
ID: "example/test-server-2",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440002",
},
},
},
}
registry.Mock.On("List", "", 30).Return(servers, "", nil)
},
expectedStatus: http.StatusOK,
expectedServers: []apiv0.ServerJSON{
{
Name: "test-server-1",
setupRegistryService: func(registry service.RegistryService) {
// Publish test servers
server1 := apiv0.ServerJSON{
Name: "com.example/test-server-1",
Description: "First test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-1",
@ -82,14 +43,9 @@ func TestServersListEndpoint(t *testing.T) {
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440001",
},
},
},
{
Name: "test-server-2",
}
server2 := apiv0.ServerJSON{
Name: "com.example/test-server-2",
Description: "Second test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-2",
@ -99,44 +55,19 @@ func TestServersListEndpoint(t *testing.T) {
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440002",
},
},
},
}
_, _ = registry.Publish(server1)
_, _ = registry.Publish(server2)
},
expectedStatus: http.StatusOK,
expectedServers: nil, // Will be verified differently since IDs are dynamic
},
{
name: "successful list with cursor and limit",
queryParams: "?cursor=550e8400-e29b-41d4-a716-446655440000&limit=10",
setupMocks: func(registry *MockRegistryService) {
servers := []apiv0.ServerJSON{
{
Name: "test-server-3",
Description: "Third test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-3",
Source: "github",
ID: "example/test-server-3",
},
VersionDetail: model.VersionDetail{
Version: "1.5.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440003",
},
},
},
}
nextCursor := uuid.New().String()
registry.Mock.On("List", mock.AnythingOfType("string"), 10).Return(servers, nextCursor, nil)
},
expectedStatus: http.StatusOK,
expectedServers: []apiv0.ServerJSON{
{
Name: "test-server-3",
queryParams: "?limit=10",
setupRegistryService: func(registry service.RegistryService) {
server := apiv0.ServerJSON{
Name: "com.example/test-server-3",
Description: "Third test server",
Repository: model.Repository{
URL: "https://github.com/example/test-server-3",
@ -146,75 +77,69 @@ func TestServersListEndpoint(t *testing.T) {
VersionDetail: model.VersionDetail{
Version: "1.5.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "550e8400-e29b-41d4-a716-446655440003",
},
},
},
}
_, _ = registry.Publish(server)
},
expectedMeta: &v0.Metadata{
NextCursor: "", // This will be dynamically set in the test
Count: 1,
expectedStatus: http.StatusOK,
expectedServers: nil, // Will be verified differently since IDs are dynamic
},
{
name: "successful list with limit capping at 100",
queryParams: "?limit=150",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
},
{
name: "invalid cursor parameter",
queryParams: "?cursor=invalid-uuid",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - non-numeric",
queryParams: "?limit=abc",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - zero",
queryParams: "?limit=0",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - negative",
queryParams: "?limit=-5",
setupRegistryService: func(_ service.RegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "empty registry returns success",
setupRegistryService: func(_ service.RegistryService) {
// Test empty registry - empty setup
},
},
{
name: "successful list with limit capping at 100",
queryParams: "?limit=150",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma rejects values > maximum
expectedError: "validation failed",
},
{
name: "invalid cursor parameter",
queryParams: "?cursor=invalid-uuid",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - non-numeric",
queryParams: "?limit=abc",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - zero",
queryParams: "?limit=0",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "invalid limit parameter - negative",
queryParams: "?limit=-5",
setupMocks: func(_ *MockRegistryService) {},
expectedStatus: http.StatusUnprocessableEntity, // Huma returns 422 for validation errors
expectedError: "validation failed",
},
{
name: "registry service error",
setupMocks: func(registry *MockRegistryService) {
registry.Mock.On("List", "", 30).Return([]apiv0.ServerJSON{}, "", errors.New("database connection error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to get registry list",
expectedStatus: http.StatusOK,
expectedServers: []apiv0.ServerJSON{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry service
mockRegistry := new(MockRegistryService)
tc.setupMocks(mockRegistry)
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
tc.setupRegistryService(registryService)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
v0.RegisterServersEndpoints(api, registryService)
// Create request
url := "/v0/servers" + tc.queryParams
@ -242,13 +167,26 @@ func TestServersListEndpoint(t *testing.T) {
// Check the response data
if tc.expectedServers != nil {
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")
for _, server := range resp.Servers {
assert.NotEmpty(t, server.Name)
assert.NotEmpty(t, server.Description)
assert.NotNil(t, server.Meta)
assert.NotNil(t, server.Meta.IOModelContextProtocolRegistry)
assert.NotEmpty(t, server.Meta.IOModelContextProtocolRegistry.ID)
}
}
// Check metadata if expected
if tc.expectedMeta != nil {
assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count)
if tc.expectedMeta.NextCursor != "" {
assert.NotEmpty(t, resp.Metadata.NextCursor)
assert.NotNil(t, resp.Metadata, "Expected metadata to be present")
if resp.Metadata != nil {
assert.Equal(t, tc.expectedMeta.Count, resp.Metadata.Count)
if tc.expectedMeta.NextCursor != "" {
assert.NotEmpty(t, resp.Metadata.NextCursor)
}
}
}
} else if tc.expectedError != "" {
@ -257,84 +195,58 @@ func TestServersListEndpoint(t *testing.T) {
}
// Verify mock expectations
mockRegistry.AssertExpectations(t)
// No expectations to verify with real service
})
}
}
func TestServersDetailEndpoint(t *testing.T) {
// Create mock registry service
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
testServer, err := registryService.Publish(apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
})
assert.NoError(t, err)
testCases := []struct {
name string
serverID string
setupMocks func(*MockRegistryService, string)
expectedStatus int
expectedServer *apiv0.ServerJSON
expectedError string
}{
{
name: "successful get server detail",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
serverDetail := &apiv0.ServerJSON{
Name: "test-server-detail",
Description: "Test server detail",
Repository: model.Repository{
URL: "https://github.com/example/test-server-detail",
Source: "github",
ID: "example/test-server-detail",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: serverID,
},
},
}
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
},
name: "successful get server detail",
serverID: testServer.Meta.IOModelContextProtocolRegistry.ID,
expectedStatus: http.StatusOK,
},
{
name: "invalid server ID format",
serverID: "invalid-uuid",
setupMocks: func(_ *MockRegistryService, _ string) {},
expectedStatus: http.StatusUnprocessableEntity,
expectedError: "validation failed",
},
{
name: "server not found",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("record not found"))
},
name: "server not found",
serverID: uuid.New().String(),
expectedStatus: http.StatusNotFound,
expectedError: "Server not found",
},
{
name: "registry service error",
serverID: uuid.New().String(),
setupMocks: func(registry *MockRegistryService, serverID string) {
registry.Mock.On("GetByID", serverID).Return(nil, errors.New("database connection error"))
},
expectedStatus: http.StatusInternalServerError,
expectedError: "Failed to get server details",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create mock registry service
mockRegistry := new(MockRegistryService)
tc.setupMocks(mockRegistry, tc.serverID)
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
v0.RegisterServersEndpoints(api, registryService)
// Create request
url := "/v0/servers/" + tc.serverID
@ -364,7 +276,7 @@ func TestServersDetailEndpoint(t *testing.T) {
}
// Verify mock expectations
mockRegistry.AssertExpectations(t)
// No expectations to verify with real service
})
}
}
@ -372,42 +284,36 @@ func TestServersDetailEndpoint(t *testing.T) {
// TestServersEndpointsIntegration tests the servers endpoints with actual HTTP requests
func TestServersEndpointsIntegration(t *testing.T) {
// Create mock registry service
mockRegistry := new(MockRegistryService)
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
// Test data
serverID := uuid.New().String()
servers := []apiv0.ServerJSON{
{
Name: "integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
URL: "https://github.com/example/integration-test",
Source: "github",
ID: "example/integration-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: serverID,
},
},
// Test data - publish a server and get its actual ID
testServer := apiv0.ServerJSON{
Name: "com.example/integration-test-server",
Description: "Integration test server",
Repository: model.Repository{
URL: "https://github.com/example/integration-test",
Source: "github",
ID: "example/integration-test",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
}
serverDetail := &servers[0]
published, err := registryService.Publish(testServer)
assert.NoError(t, err)
assert.NotNil(t, published)
// Setup mocks
mockRegistry.Mock.On("List", "", 30).Return(servers, "", nil)
mockRegistry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
serverID := published.Meta.IOModelContextProtocolRegistry.ID
servers := []apiv0.ServerJSON{*published}
serverDetail := published
// Create a new test API
mux := http.NewServeMux()
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
// Register the servers endpoints
v0.RegisterServersEndpoints(api, mockRegistry)
v0.RegisterServersEndpoints(api, registryService)
// Create test server
server := httptest.NewServer(mux)
@ -441,8 +347,14 @@ func TestServersEndpointsIntegration(t *testing.T) {
err = json.NewDecoder(resp.Body).Decode(&listResp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, servers, listResp.Servers)
// Check the response data (excluding timestamps which will be different)
assert.Len(t, listResp.Servers, len(servers))
if len(listResp.Servers) > 0 {
assert.Equal(t, servers[0].Name, listResp.Servers[0].Name)
assert.Equal(t, servers[0].Description, listResp.Servers[0].Description)
assert.Equal(t, servers[0].Repository, listResp.Servers[0].Repository)
assert.Equal(t, servers[0].VersionDetail, listResp.Servers[0].VersionDetail)
}
})
// Test get server detail endpoint
@ -471,10 +383,13 @@ func TestServersEndpointsIntegration(t *testing.T) {
err = json.NewDecoder(resp.Body).Decode(&serverDetailResp)
assert.NoError(t, err)
// Check the response data
assert.Equal(t, *serverDetail, serverDetailResp)
// Check the response data (excluding timestamps which will be different)
assert.Equal(t, serverDetail.Name, serverDetailResp.Name)
assert.Equal(t, serverDetail.Description, serverDetailResp.Description)
assert.Equal(t, serverDetail.Repository, serverDetailResp.Repository)
assert.Equal(t, serverDetail.VersionDetail, serverDetailResp.VersionDetail)
})
// Verify mock expectations
mockRegistry.AssertExpectations(t)
// No expectations to verify with real service
}

View file

@ -8,43 +8,33 @@ import (
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/api/router"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/modelcontextprotocol/registry/internal/telemetry"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
func mockServerEndpoint(registry *MockRegistryService, serverID string) {
serverDetail := &apiv0.ServerJSON{
Name: "test-server-detail",
func TestPrometheusHandler(t *testing.T) {
registryService := service.NewRegistryServiceWithDB(database.NewMemoryDB())
server, err := registryService.Publish(apiv0.ServerJSON{
Name: "io.github.example/test-server",
Description: "Test server detail",
Repository: model.Repository{
URL: "https://github.com/example/test-server-detail",
URL: "https://github.com/example/test-server",
Source: "github",
ID: "example/test-server-detail",
ID: "example/test-server",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: serverID,
},
},
}
registry.Mock.On("GetByID", serverID).Return(serverDetail, nil)
}
func TestPrometheusHandler(t *testing.T) {
mockRegistry := new(MockRegistryService)
serverID := uuid.New().String()
mockServerEndpoint(mockRegistry, serverID)
})
assert.NoError(t, err)
cfg := config.NewConfig()
shutdownTelemetry, metrics, _ := telemetry.InitMetrics("dev")
@ -57,13 +47,13 @@ func TestPrometheusHandler(t *testing.T) {
router.WithSkipPaths("/health", "/metrics", "/ping", "/docs"),
))
v0.RegisterHealthEndpoint(api, cfg, metrics)
v0.RegisterServersEndpoints(api, mockRegistry)
v0.RegisterServersEndpoints(api, registryService)
// Add /metrics for Prometheus metrics using promhttp
mux.Handle("/metrics", metrics.PrometheusHandler())
// Create request
url := "/v0/servers/" + serverID
url := "/v0/servers/" + server.Meta.IOModelContextProtocolRegistry.ID
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()

View file

@ -17,21 +17,22 @@ var (
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
)
// Database defines the interface for database operations with extension wrapper architecture
// ServerFilter defines filtering options for server queries
type ServerFilter struct {
Name *string // for finding versions of same server
RemoteURL *string // for duplicate URL detection
}
// Database defines the interface for database operations
type Database interface {
// List retrieves all ServerRecord entries with optional filtering
List(ctx context.Context, filter map[string]any, cursor string, limit int) ([]*apiv0.ServerJSON, string, error)
// GetByID retrieves a single ServerRecord by its ID
// Retrieve server entries with optional filtering
List(ctx context.Context, filter *ServerFilter, cursor string, limit int) ([]*apiv0.ServerJSON, string, error)
// Retrieve a single server by its ID
GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error)
// Publish adds a new server to the database with separated server.json and extensions
// The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps)
Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error)
// UpdateLatestFlag updates the is_latest flag for a specific server record
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
// UpdateServer updates an existing server record with new server details
UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error)
// ImportSeed imports initial data from a seed file
ImportSeed(ctx context.Context, seedFilePath string) error
// CreateServer adds a new server to the database
CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// UpdateServer updates an existing server record
UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// Close closes the database connection
Close() error
}
@ -45,15 +46,3 @@ const (
// ConnectionTypePostgreSQL represents a PostgreSQL database connection
ConnectionTypePostgreSQL ConnectionType = "postgresql"
)
// ConnectionInfo provides information about the database connection
type ConnectionInfo struct {
// Type indicates the type of database connection
Type ConnectionType
// IsConnected indicates whether the database is currently connected
IsConnected bool
// Raw provides access to the underlying connection object, which will vary by implementation
// For PostgreSQL, this will be *pgx.Conn
// For MemoryDB, this will be map[string]*model.MCPRegistry
Raw any
}

View file

@ -1,268 +0,0 @@
package database_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/modelcontextprotocol/registry/internal/database"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
)
func TestReadSeedFile_LocalFile(t *testing.T) {
// Create a temporary seed file in extension wrapper format
tempFile := "/tmp/test_seed.json"
seedData := []apiv0.ServerJSON{
{
Name: "com.example/test-server-1",
Description: "Test server 1",
Repository: model.Repository{
URL: "https://github.com/test/repo1",
Source: "github",
ID: "123",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Remotes: []model.Remote{
{
URL: "https://example.com/remote",
},
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "test-id-1",
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
IsLatest: true,
ReleaseDate: "2023-01-01T00:00:00Z",
},
},
},
}
// Write seed data to temp file
data, err := json.Marshal(seedData)
assert.NoError(t, err)
err = func() error {
f, err := os.Create(tempFile)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(data)
return err
}()
assert.NoError(t, err)
defer os.Remove(tempFile)
// Test reading the file
result, err := database.ReadSeedFile(context.Background(), tempFile)
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "com.example/test-server-1", result[0].Name)
}
func TestReadSeedFile_DirectHTTPURL(t *testing.T) {
// Create a test HTTP server that serves seed JSON directly in extension wrapper format
seedData := []apiv0.ServerJSON{
{
Name: "com.example/test-server-1",
Description: "Test server 1",
Remotes: []model.Remote{
{
URL: "https://example.com/remote",
},
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "test-id-1",
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
IsLatest: true,
ReleaseDate: "2023-01-01T00:00:00Z",
},
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(seedData); err != nil {
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
}
}))
defer server.Close()
// Test reading from HTTP URL ending in .json
result, err := database.ReadSeedFile(context.Background(), server.URL+"/seed.json")
assert.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "com.example/test-server-1", result[0].Name)
}
func TestReadSeedFile_RegistryURL(t *testing.T) {
// Create mock registry responses
server1 := apiv0.ServerJSON{
Name: "Test Server 1",
Description: "First test server",
Packages: []model.Package{
{
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
Identifier: "test-package-1",
Version: "1.0.0",
},
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "server-1",
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
IsLatest: true,
ReleaseDate: "2023-01-01T00:00:00Z",
},
},
}
server2 := apiv0.ServerJSON{
Name: "Test Server 2",
Description: "Second test server",
Packages: []model.Package{
{
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
Identifier: "test-package-2",
Version: "2.0.0",
},
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "server-2",
PublishedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC),
IsLatest: true,
ReleaseDate: "2023-01-01T00:00:00Z",
},
},
}
serverDetail1 := apiv0.ServerJSON{
Name: "Test Server 1",
Description: "First test server",
Packages: []model.Package{
{
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
Identifier: "test-package-1",
Version: "1.0.0",
},
},
}
serverDetail2 := apiv0.ServerJSON{
Name: "Test Server 2",
Description: "Second test server",
Packages: []model.Package{
{
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
Identifier: "test-package-2",
Version: "2.0.0",
},
},
}
// Create a test HTTP server that simulates the registry API
mux := http.NewServeMux()
// Handle /v0/servers endpoint (paginated)
mux.HandleFunc("/v0/servers", func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
type Metadata struct {
NextCursor string `json:"next_cursor,omitempty"`
Count int `json:"count,omitempty"`
}
type PaginatedResponse struct {
Servers []apiv0.ServerJSON `json:"servers"`
Metadata *Metadata `json:"metadata,omitempty"`
}
var response PaginatedResponse
switch cursor {
case "":
// First page
response = PaginatedResponse{
Servers: []apiv0.ServerJSON{server1},
Metadata: &Metadata{
NextCursor: "next-cursor-1",
Count: 1,
},
}
case "next-cursor-1":
// Second page
response = PaginatedResponse{
Servers: []apiv0.ServerJSON{server2},
Metadata: &Metadata{
Count: 1,
// No NextCursor means end of pagination
},
}
default:
// No more pages
response = PaginatedResponse{
Servers: []apiv0.ServerJSON{},
Metadata: &Metadata{},
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
}
})
// Handle individual server detail endpoints
mux.HandleFunc("/v0/servers/server-1", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(serverDetail1); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
mux.HandleFunc("/v0/servers/server-2", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(serverDetail2); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
server := httptest.NewServer(mux)
defer server.Close()
// Test reading from registry root URL (this should trigger pagination)
result, err := database.ReadSeedFile(context.Background(), server.URL+"/v0/servers")
assert.NoError(t, err)
assert.Len(t, result, 2)
// Verify the servers were imported correctly
assert.Equal(t, "Test Server 1", result[0].Name)
assert.Equal(t, "Test Server 2", result[1].Name)
// Verify packages were imported with new schema
assert.Len(t, result[0].Packages, 1)
assert.Equal(t, "npm", result[0].Packages[0].RegistryType)
assert.Equal(t, "https://registry.npmjs.org", result[0].Packages[0].RegistryBaseURL)
assert.Equal(t, "test-package-1", result[0].Packages[0].Identifier)
assert.Len(t, result[1].Packages, 1)
assert.Equal(t, "npm", result[1].Packages[0].RegistryType)
assert.Equal(t, "https://registry.npmjs.org", result[1].Packages[0].RegistryBaseURL)
assert.Equal(t, "test-package-2", result[1].Packages[0].Identifier)
// Verify metadata was extracted
assert.Equal(t, "server-1", result[0].Meta.IOModelContextProtocolRegistry.ID)
assert.Equal(t, "server-2", result[1].Meta.IOModelContextProtocolRegistry.ID)
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"sort"
"sync"
"time"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
@ -16,38 +15,17 @@ type MemoryDB struct {
mu sync.RWMutex
}
// NewMemoryDB creates a new instance of the in-memory database
func NewMemoryDB(e map[string]*apiv0.ServerJSON) *MemoryDB {
func NewMemoryDB() *MemoryDB {
// Convert input ServerJSON entries to have proper metadata
serverRecords := make(map[string]*apiv0.ServerJSON)
for registryID, serverDetail := range e {
// Create a copy and add registry metadata
now := time.Now()
server := *serverDetail // Copy the server
if server.Meta == nil {
server.Meta = &apiv0.ServerMeta{}
}
server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{
ID: registryID,
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
}
if server.Meta.Publisher == nil {
server.Meta.Publisher = make(map[string]interface{})
}
serverRecords[registryID] = &server
}
return &MemoryDB{
entries: serverRecords,
}
}
//nolint:cyclop,gocyclo,gocognit // Complexity from filtering logic is acceptable for memory implementation
func (db *MemoryDB) List(
ctx context.Context,
filter map[string]any,
filter *ServerFilter,
cursor string,
limit int,
) ([]*apiv0.ServerJSON, string, error) {
@ -62,73 +40,20 @@ func (db *MemoryDB) List(
db.mu.RLock()
defer db.mu.RUnlock()
// Convert all entries to a slice for pagination, filter by is_latest
// Convert all entries to a slice for pagination
var allEntries []*apiv0.ServerJSON
for _, entry := range db.entries {
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil && entry.Meta.IOModelContextProtocolRegistry.IsLatest {
allEntries = append(allEntries, entry)
}
allEntries = append(allEntries, entry)
}
// Simple filtering implementation
var filteredEntries []*apiv0.ServerJSON
for _, entry := range allEntries {
include := true
// Apply filtering and sorting
filteredEntries := db.filterAndSort(allEntries, filter)
// Apply filters if any
for key, value := range filter {
switch key {
case "name":
if entry.Name != value.(string) {
include = false
}
case "version":
if entry.VersionDetail.Version != value.(string) {
include = false
}
case "status":
if string(entry.Status) != value.(string) {
include = false
}
case "remote_url":
found := false
remoteURL := value.(string)
for _, remote := range entry.Remotes {
if remote.URL == remoteURL {
found = true
break
}
}
if !found {
include = false
}
}
}
if include {
filteredEntries = append(filteredEntries, entry)
}
}
// Sort filteredEntries by registry metadata ID for consistent pagination
sort.Slice(filteredEntries, func(i, j int) bool {
iID := ""
jID := ""
if filteredEntries[i].Meta != nil && filteredEntries[i].Meta.IOModelContextProtocolRegistry != nil {
iID = filteredEntries[i].Meta.IOModelContextProtocolRegistry.ID
}
if filteredEntries[j].Meta != nil && filteredEntries[j].Meta.IOModelContextProtocolRegistry != nil {
jID = filteredEntries[j].Meta.IOModelContextProtocolRegistry.ID
}
return iID < jID
})
// Find starting point for cursor-based pagination using registry metadata ID
// Find starting point for cursor-based pagination
startIdx := 0
if cursor != "" {
for i, entry := range filteredEntries {
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil &&
entry.Meta.IOModelContextProtocolRegistry.ID == cursor {
if db.getRegistryID(entry) == cursor {
startIdx = i + 1 // Start after the cursor
break
}
@ -145,19 +70,15 @@ func (db *MemoryDB) List(
result = []*apiv0.ServerJSON{}
}
// Determine next cursor using registry metadata ID
// Determine next cursor
nextCursor := ""
if endIdx < len(filteredEntries) && len(result) > 0 {
lastEntry := result[len(result)-1]
if lastEntry.Meta != nil && lastEntry.Meta.IOModelContextProtocolRegistry != nil {
nextCursor = lastEntry.Meta.IOModelContextProtocolRegistry.ID
}
nextCursor = db.getRegistryID(result[len(result)-1])
}
return result, nextCursor, nil
}
// GetByID retrieves a single ServerRecord by its registry metadata ID
func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
@ -176,103 +97,28 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON,
return nil, ErrNotFound
}
// Publish adds a new server to the database with separated server.json and extensions
func (db *MemoryDB) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) {
func (db *MemoryDB) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Extract name and version for validation
name := serverDetail.Name
if name == "" {
return nil, fmt.Errorf("name is required in server JSON")
// Get the ID from the registry metadata
if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil {
return nil, fmt.Errorf("server must have registry metadata with ID")
}
version := serverDetail.VersionDetail.Version
if version == "" {
return nil, fmt.Errorf("version is required in version_detail")
}
id := server.Meta.IOModelContextProtocolRegistry.ID
db.mu.Lock()
defer db.mu.Unlock()
// Validate repository URL
if serverDetail.Repository.URL == "" {
return nil, ErrInvalidInput
}
// Create server record with metadata
record := serverDetail // Copy the input
// Initialize meta if not present
if record.Meta == nil {
record.Meta = &apiv0.ServerMeta{}
}
// Set registry metadata
record.Meta.IOModelContextProtocolRegistry = &registryMetadata
// Set publisher extensions if provided
if len(publisherExtensions) > 0 {
record.Meta.Publisher = publisherExtensions
}
// Store the record using registry metadata ID
db.entries[registryMetadata.ID] = &record
db.entries[id] = server
return &record, nil
return server, nil
}
// ImportSeed imports initial data from a seed file into memory database
func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
if ctx.Err() != nil {
return ctx.Err()
}
// This will need to be updated to work with the new ServerRecord format
// Read seed data using the shared ReadSeedFile function
seedData, err := ReadSeedFile(ctx, seedFilePath)
if err != nil {
return fmt.Errorf("failed to read seed file: %w", err)
}
// Lock for concurrent access
db.mu.Lock()
defer db.mu.Unlock()
// Import each server
for _, record := range seedData {
// Use the registry metadata ID as the map key
if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil {
db.entries[record.Meta.IOModelContextProtocolRegistry.ID] = record
}
}
return nil
}
// UpdateLatestFlag updates the is_latest flag for a specific server record
func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
if ctx.Err() != nil {
return ctx.Err()
}
db.mu.Lock()
defer db.mu.Unlock()
if entry, exists := db.entries[id]; exists {
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil {
entry.Meta.IOModelContextProtocolRegistry.IsLatest = isLatest
entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
}
return nil
}
return ErrNotFound
}
// UpdateServer updates an existing server record with new server details
func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) {
func (db *MemoryDB) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -280,40 +126,75 @@ func (db *MemoryDB) UpdateServer(ctx context.Context, id string, serverDetail ap
db.mu.Lock()
defer db.mu.Unlock()
entry, exists := db.entries[id]
_, exists := db.entries[id]
if !exists {
return nil, ErrNotFound
}
// Update the server details by copying fields from serverDetail
*entry = serverDetail
// Ensure meta exists and update it
if entry.Meta == nil {
entry.Meta = &apiv0.ServerMeta{}
}
if entry.Meta.IOModelContextProtocolRegistry != nil {
entry.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
}
if len(publisherExtensions) > 0 {
entry.Meta.Publisher = publisherExtensions
}
// Update the server
db.entries[id] = server
// Return the updated record
return entry, nil
return server, nil
}
// Close closes the database connection
// For an in-memory database, this is a no-op
func (db *MemoryDB) Close() error {
return nil
}
// Connection returns information about the database connection
func (db *MemoryDB) Connection() *ConnectionInfo {
return &ConnectionInfo{
Type: ConnectionTypeMemory,
IsConnected: true, // Memory DB is always connected
Raw: db.entries,
// filterAndSort applies filtering and sorting to the entries
func (db *MemoryDB) filterAndSort(allEntries []*apiv0.ServerJSON, filter *ServerFilter) []*apiv0.ServerJSON {
// Apply filtering
var filteredEntries []*apiv0.ServerJSON
for _, entry := range allEntries {
if db.matchesFilter(entry, filter) {
filteredEntries = append(filteredEntries, entry)
}
}
// Sort by registry metadata ID for consistent pagination
sort.Slice(filteredEntries, func(i, j int) bool {
iID := db.getRegistryID(filteredEntries[i])
jID := db.getRegistryID(filteredEntries[j])
return iID < jID
})
return filteredEntries
}
// matchesFilter checks if an entry matches the provided filter
func (db *MemoryDB) matchesFilter(entry *apiv0.ServerJSON, filter *ServerFilter) bool {
if filter == nil {
return true
}
// Check name filter
if filter.Name != nil && entry.Name != *filter.Name {
return false
}
// Check remote URL filter
if filter.RemoteURL != nil {
found := false
for _, remote := range entry.Remotes {
if remote.URL == *filter.RemoteURL {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// getRegistryID safely extracts the registry ID from an entry
func (db *MemoryDB) getRegistryID(entry *apiv0.ServerJSON) string {
if entry.Meta != nil && entry.Meta.IOModelContextProtocolRegistry != nil {
return entry.Meta.IOModelContextProtocolRegistry.ID
}
return ""
}

View file

@ -5,9 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@ -42,10 +40,9 @@ func NewPostgreSQL(ctx context.Context, connectionURI string) (*PostgreSQL, erro
}, nil
}
// List retrieves ServerJSON entries with optional filtering and pagination
func (db *PostgreSQL) List(
ctx context.Context,
filter map[string]any,
filter *ServerFilter,
cursor string,
limit int,
) ([]*apiv0.ServerJSON, string, error) {
@ -57,30 +54,21 @@ func (db *PostgreSQL) List(
return nil, "", ctx.Err()
}
// Build WHERE clause for filtering - always filter by is_latest = true by default
// Build WHERE clause for filtering
var whereConditions []string
whereConditions = append(whereConditions, "(value->'_meta'->'io.modelcontextprotocol.registry'->>'is_latest')::boolean = true")
args := []any{}
argIndex := 1
// Add filters using JSON operators
for k, v := range filter {
switch k {
case "name":
if filter != nil {
if filter.Name != nil {
whereConditions = append(whereConditions, fmt.Sprintf("value->>'name' = $%d", argIndex))
args = append(args, v)
args = append(args, *filter.Name)
argIndex++
case "version":
whereConditions = append(whereConditions, fmt.Sprintf("value->'version_detail'->>'version' = $%d", argIndex))
args = append(args, v)
argIndex++
case "status":
whereConditions = append(whereConditions, fmt.Sprintf("value->>'status' = $%d", argIndex))
args = append(args, v)
argIndex++
case "remote_url":
}
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, v)
args = append(args, *filter.RemoteURL)
argIndex++
}
}
@ -151,7 +139,6 @@ func (db *PostgreSQL) List(
return results, nextCursor, nil
}
// GetByID retrieves a single ServerJSON by its registry metadata ID
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
@ -183,27 +170,18 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv0.ServerJSON
return &serverJSON, nil
}
// Publish adds a new server to the simple servers table
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv0.RegistryExtensions) (*apiv0.ServerJSON, error) {
// CreateServer adds a new server to the database
func (db *PostgreSQL) CreateServer(ctx context.Context, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Create complete server with metadata
server := serverDetail // Copy the input
// Initialize meta if not present
if server.Meta == nil {
server.Meta = &apiv0.ServerMeta{}
// Get the ID from the registry metadata
if server.Meta == nil || server.Meta.IOModelContextProtocolRegistry == nil {
return nil, fmt.Errorf("server must have registry metadata with ID")
}
// Set registry metadata
server.Meta.IOModelContextProtocolRegistry = &registryMetadata
// Set publisher extensions if provided
if len(publisherExtensions) > 0 {
server.Meta.Publisher = publisherExtensions
}
id := server.Meta.IOModelContextProtocolRegistry.ID
// Marshal the complete server to JSONB
valueJSON, err := json.Marshal(server)
@ -217,130 +195,22 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail apiv0.ServerJSON
VALUES ($1, $2)
`
_, err = db.conn.Exec(ctx, query, registryMetadata.ID, valueJSON)
_, err = db.conn.Exec(ctx, query, id, valueJSON)
if err != nil {
return nil, fmt.Errorf("failed to insert server: %w", err)
}
return &server, nil
}
// ImportSeed imports initial data from a seed file into PostgreSQL
func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error {
// Read seed data using the shared ReadSeedFile function
seedData, err := ReadSeedFile(ctx, seedFilePath)
if err != nil {
return fmt.Errorf("failed to read seed file: %w", err)
}
// Start a transaction for batch import
tx, err := db.conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(ctx); rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
log.Printf("Failed to rollback transaction: %v", rollbackErr)
}
}()
// Import each server
for _, record := range seedData {
// Marshal the server to JSONB
valueJSON, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("failed to marshal server JSON for import: %w", err)
}
// Get registry ID from metadata
var registryID string
if record.Meta != nil && record.Meta.IOModelContextProtocolRegistry != nil {
registryID = record.Meta.IOModelContextProtocolRegistry.ID
} else {
registryID = uuid.New().String() // Generate ID if not present
}
// Insert into simple servers table
_, err = tx.Exec(ctx, "INSERT INTO servers (id, value) VALUES ($1, $2)", registryID, valueJSON)
if err != nil {
return fmt.Errorf("failed to import server %s: %w", record.Name, err)
}
}
// Commit the transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit seed import transaction: %w", err)
}
return nil
}
// UpdateLatestFlag updates the is_latest flag for a specific server record
func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
if ctx.Err() != nil {
return ctx.Err()
}
query := `
UPDATE servers
SET value = jsonb_set(
jsonb_set(
value,
'{_meta,io.modelcontextprotocol.registry,is_latest}',
$1::jsonb
),
'{_meta,io.modelcontextprotocol.registry,updated_at}',
$2::jsonb
)
WHERE (value->'_meta'->'io.modelcontextprotocol.registry'->>'id') = $3
`
result, err := db.conn.Exec(ctx, query,
fmt.Sprintf("%t", isLatest),
fmt.Sprintf("\"%s\"", time.Now().Format(time.RFC3339)),
id)
if err != nil {
return fmt.Errorf("failed to update latest flag: %w", err)
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
return server, nil
}
// UpdateServer updates an existing server record with new server details
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail apiv0.ServerJSON, publisherExtensions map[string]interface{}) (*apiv0.ServerJSON, error) {
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, server *apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Get the existing server to preserve registry metadata
existingServer, err := db.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Update the server details while preserving registry metadata
updatedServer := serverDetail
if updatedServer.Meta == nil {
updatedServer.Meta = &apiv0.ServerMeta{}
}
// Preserve existing registry metadata but update timestamp
if existingServer.Meta != nil && existingServer.Meta.IOModelContextProtocolRegistry != nil {
updatedServer.Meta.IOModelContextProtocolRegistry = existingServer.Meta.IOModelContextProtocolRegistry
updatedServer.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
}
// Update publisher extensions if provided
if len(publisherExtensions) > 0 {
updatedServer.Meta.Publisher = publisherExtensions
}
// Marshal updated server
valueJSON, err := json.Marshal(updatedServer)
valueJSON, err := json.Marshal(server)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated server: %w", err)
}
@ -361,27 +231,10 @@ func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail
return nil, ErrNotFound
}
return &updatedServer, nil
return server, nil
}
// Close closes the database connection
func (db *PostgreSQL) Close() error {
return db.conn.Close(context.Background())
}
// Connection returns information about the database connection
func (db *PostgreSQL) Connection() *ConnectionInfo {
isConnected := false
if db.conn != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := db.conn.Ping(ctx)
isConnected = (err == nil)
}
return &ConnectionInfo{
Type: ConnectionTypePostgreSQL,
IsConnected: isConnected,
Raw: db.conn,
}
}

View file

@ -0,0 +1,184 @@
package importer
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
// Service handles importing seed data into the registry
type Service struct {
db database.Database
}
// NewService creates a new importer service
func NewService(db database.Database) *Service {
return &Service{db: db}
}
// ImportFromPath imports seed data from various sources:
// 1. Local file paths (*.json files) - expects extension wrapper format
// 2. Direct HTTP URLs to seed.json files - expects extension wrapper format
// 3. Registry root URLs (automatically appends /v0/servers and paginates)
func (s *Service) ImportFromPath(ctx context.Context, path string) error {
servers, err := readSeedFile(ctx, path)
if err != nil {
return fmt.Errorf("failed to read seed data: %w", err)
}
// Import each server using CreateServer
for _, server := range servers {
_, err := s.db.CreateServer(ctx, server)
if err != nil {
return fmt.Errorf("failed to import server %s: %w", server.Name, err)
}
}
return nil
}
// readSeedFile reads seed data from various sources
func readSeedFile(ctx context.Context, path string) ([]*apiv0.ServerJSON, error) {
var data []byte
var err error
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
// Handle HTTP URLs
if strings.HasSuffix(path, "/v0/servers") || strings.Contains(path, "/v0/servers") {
// This is a registry API endpoint - fetch paginated data
return fetchFromRegistryAPI(ctx, path)
}
// This is a direct file URL
data, err = fetchFromHTTP(ctx, path)
} else {
// Handle local file paths
data, err = os.ReadFile(path)
}
if err != nil {
return nil, fmt.Errorf("failed to read seed data from %s: %w", path, err)
}
// Parse extension wrapper format (only supported format)
var serverResponses []apiv0.ServerJSON
if err := json.Unmarshal(data, &serverResponses); err != nil {
return nil, fmt.Errorf("failed to parse seed data as extension wrapper format: %w", err)
}
if len(serverResponses) == 0 {
return []*apiv0.ServerJSON{}, nil
}
// Validate servers and collect warnings instead of failing the whole batch
var validRecords []*apiv0.ServerJSON
var invalidServers []string
var validationFailures []string
for _, response := range serverResponses {
if err := validators.ValidateServerJSON(&response); err != nil {
// Log warning and track invalid server instead of failing
invalidServers = append(invalidServers, response.Name)
validationFailures = append(validationFailures, fmt.Sprintf("Server '%s': %v", response.Name, err))
log.Printf("Warning: Skipping invalid server '%s': %v", response.Name, err)
continue
}
// Convert valid ServerJSON to ServerRecord
record := convertServerResponseToRecord(response)
validRecords = append(validRecords, record)
}
// Print summary of validation results
if len(invalidServers) > 0 {
log.Printf("Import summary: %d valid servers imported, %d invalid servers skipped", len(validRecords), len(invalidServers))
log.Printf("Invalid servers: %v", invalidServers)
for _, failure := range validationFailures {
log.Printf(" - %s", failure)
}
} else {
log.Printf("Import summary: All %d servers imported successfully", len(validRecords))
}
return validRecords, nil
}
func fetchFromHTTP(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch from HTTP: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP request failed with status: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func fetchFromRegistryAPI(ctx context.Context, baseURL string) ([]*apiv0.ServerJSON, error) {
var allRecords []*apiv0.ServerJSON
cursor := ""
for {
url := baseURL
if cursor != "" {
if strings.Contains(url, "?") {
url += "&cursor=" + cursor
} else {
url += "?cursor=" + cursor
}
}
data, err := fetchFromHTTP(ctx, url)
if err != nil {
return nil, fmt.Errorf("failed to fetch page from registry API: %w", err)
}
var response struct {
Servers []apiv0.ServerJSON `json:"servers"`
Metadata *struct {
NextCursor string `json:"next_cursor,omitempty"`
} `json:"metadata,omitempty"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse registry API response: %w", err)
}
// Convert and add servers
for _, serverResponse := range response.Servers {
record := convertServerResponseToRecord(serverResponse)
allRecords = append(allRecords, record)
}
// Check if there's a next page
if response.Metadata == nil || response.Metadata.NextCursor == "" {
break
}
cursor = response.Metadata.NextCursor
}
return allRecords, nil
}
func convertServerResponseToRecord(response apiv0.ServerJSON) *apiv0.ServerJSON {
// The response is already in the correct flattened format
// Just return a pointer to it
return &response
}

View file

@ -0,0 +1,201 @@
package importer_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/importer"
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 TestImportService_LocalFile(t *testing.T) {
// Create a temporary seed file
tempFile := "/tmp/test_import_seed.json"
seedData := []apiv0.ServerJSON{
{
Name: "io.github.test/test-server-1",
Description: "Test server 1",
Repository: model.Repository{
URL: "https://github.com/test/repo1",
Source: "github",
ID: "123",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "test-id-1",
PublishedAt: time.Now(),
UpdatedAt: time.Now(),
IsLatest: true,
},
},
},
}
jsonData, err := json.Marshal(seedData)
require.NoError(t, err)
err = os.WriteFile(tempFile, jsonData, 0600)
require.NoError(t, err)
defer os.Remove(tempFile)
memDB := database.NewMemoryDB()
// Create importer service and test import
service := importer.NewService(memDB)
err = service.ImportFromPath(context.Background(), tempFile)
require.NoError(t, err)
// Verify the server was imported
servers, _, err := memDB.List(context.Background(), nil, "", 10)
require.NoError(t, err)
assert.Len(t, servers, 1)
assert.Equal(t, "io.github.test/test-server-1", servers[0].Name)
}
func TestImportService_HTTPFile(t *testing.T) {
// Create a test HTTP server
seedData := []apiv0.ServerJSON{
{
Name: "io.github.test/http-test-server",
Description: "HTTP test server",
Repository: model.Repository{
URL: "https://github.com/test/repo1",
Source: "github",
ID: "123",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "test-id-2",
PublishedAt: time.Now(),
UpdatedAt: time.Now(),
IsLatest: true,
},
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(seedData)
}))
defer server.Close()
memDB := database.NewMemoryDB()
// Create importer service and test import
service := importer.NewService(memDB)
err := service.ImportFromPath(context.Background(), server.URL+"/seed.json")
require.NoError(t, err)
// Verify the server was imported
servers, _, err := memDB.List(context.Background(), nil, "", 10)
require.NoError(t, err)
assert.Len(t, servers, 1)
assert.Equal(t, "io.github.test/http-test-server", servers[0].Name)
}
func TestImportService_RegistryAPI(t *testing.T) {
// Create test data
testServers := []apiv0.ServerJSON{
{
Name: "io.github.test/api-server-1",
Description: "API server 1",
Repository: model.Repository{
URL: "https://github.com/test/repo1",
Source: "github",
ID: "123",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "api-test-id-1",
PublishedAt: time.Now(),
UpdatedAt: time.Now(),
IsLatest: true,
},
},
},
{
Name: "io.github.test/api-server-2",
Description: "API server 2",
Repository: model.Repository{
URL: "https://github.com/test/repo2",
Source: "github",
ID: "456",
},
VersionDetail: model.VersionDetail{
Version: "2.0.0",
},
Meta: &apiv0.ServerMeta{
IOModelContextProtocolRegistry: &apiv0.RegistryExtensions{
ID: "api-test-id-2",
PublishedAt: time.Now(),
UpdatedAt: time.Now(),
IsLatest: true,
},
},
},
}
// Create test server that responds with paginated results
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
var response struct {
Servers []apiv0.ServerJSON `json:"servers"`
Metadata *struct {
NextCursor string `json:"next_cursor,omitempty"`
} `json:"metadata,omitempty"`
}
switch cursor {
case "":
// First page
response.Servers = []apiv0.ServerJSON{testServers[0]}
response.Metadata = &struct {
NextCursor string `json:"next_cursor,omitempty"`
}{NextCursor: "page2"}
case "page2":
// Second page
response.Servers = []apiv0.ServerJSON{testServers[1]}
// No next cursor means end of results
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer server.Close()
memDB := database.NewMemoryDB()
// Create importer service and test import
service := importer.NewService(memDB)
err := service.ImportFromPath(context.Background(), server.URL+"/v0/servers")
require.NoError(t, err)
// Verify both servers were imported
servers, _, err := memDB.List(context.Background(), nil, "", 10)
require.NoError(t, err)
assert.Len(t, servers, 2)
names := []string{servers[0].Name, servers[1].Name}
assert.Contains(t, names, "io.github.test/api-server-1")
assert.Contains(t, names, "io.github.test/api-server-2")
}

View file

@ -1,176 +0,0 @@
package service
import (
"context"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
// fakeRegistryService implements RegistryService interface with an in-memory database
type fakeRegistryService struct {
db *database.MemoryDB
}
// NewFakeRegistryService creates a new fake registry service with pre-populated data
func NewFakeRegistryService() RegistryService {
// Sample registry entries with updated model structure using ServerJSON
serverDetails := []*apiv0.ServerJSON{
{
Name: "bluegreen/mcp-server",
Description: "A dummy MCP registry for testing",
Repository: model.Repository{
URL: "https://github.com/example/mcp-1",
Source: "github",
ID: "example/mcp-1",
},
VersionDetail: model.VersionDetail{
Version: "1.0.0",
},
},
{
Name: "orangepurple/mcp-server",
Description: "Another dummy MCP registry for testing",
Repository: model.Repository{
URL: "https://github.com/example/mcp-2",
Source: "github",
ID: "example/mcp-2",
},
VersionDetail: model.VersionDetail{
Version: "0.9.0",
},
},
{
Name: "greenyellow/mcp-server",
Description: "Yet another dummy MCP registry for testing",
Repository: model.Repository{
URL: "https://github.com/example/mcp-3",
Source: "github",
ID: "example/mcp-3",
},
VersionDetail: model.VersionDetail{
Version: "0.9.5",
},
},
}
// Create a new in-memory database using registry metadata IDs
serverDetailMap := make(map[string]*apiv0.ServerJSON)
for _, entry := range serverDetails {
registryID := uuid.New().String() // Generate registry metadata ID
serverDetailMap[registryID] = entry
}
memDB := database.NewMemoryDB(serverDetailMap)
return &fakeRegistryService{
db: memDB,
}
}
// List retrieves servers with extension wrapper format
func (s *fakeRegistryService) List(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()
// Use the database's List method to get ServerRecord entries
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
if err != nil {
return nil, "", err
}
// Return ServerRecords directly (they're now the same as ServerResponse)
result := make([]apiv0.ServerJSON, len(serverRecords))
for i, record := range serverRecords {
result[i] = *record
}
return result, nextCursor, nil
}
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *fakeRegistryService) GetByID(id string) (*apiv0.ServerJSON, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's GetByID method to retrieve the server record
serverRecord, err := s.db.GetByID(ctx, id)
if err != nil {
return nil, err
}
// Return ServerRecord directly (it's now the same as ServerResponse)
return serverRecord, nil
}
// Publish publishes a server with separated extensions
func (s *fakeRegistryService) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
// Create a timeout context for the database operation
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Validate the request
if err := validators.ValidatePublishRequest(req); err != nil {
return nil, err
}
// Extract publisher extensions from _meta.publisher
publisherExtensions := make(map[string]interface{})
if req.Meta != nil && req.Meta.Publisher != nil {
publisherExtensions = req.Meta.Publisher
}
// Create registry metadata for fake service (always marks as latest)
now := time.Now()
registryMetadata := apiv0.RegistryExtensions{
ID: uuid.New().String(),
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
}
// Publish to database
serverRecord, err := s.db.Publish(ctx, req, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}
// Return ServerRecord directly (it's now the same as ServerResponse)
return serverRecord, nil
}
// EditServer updates an existing server with new details (admin operation)
func (s *fakeRegistryService) EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Validate the request
if err := validators.ValidatePublishRequest(req); err != nil {
return nil, err
}
// Extract publisher extensions from _meta.publisher
publisherExtensions := make(map[string]interface{})
if req.Meta != nil && req.Meta.Publisher != nil {
publisherExtensions = req.Meta.Publisher
}
// Update server in database
serverRecord, err := s.db.UpdateServer(ctx, id, req, publisherExtensions)
if err != nil {
return nil, err
}
// Return ServerRecord directly (it's now the same as ServerResponse)
return serverRecord, nil
}
// Close closes the in-memory database connection
func (s *fakeRegistryService) Close() error {
return s.db.Close()
}

View file

@ -37,7 +37,7 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerJSON
limit = 30
}
// Use the database's List method with pagination
// Use the database's ListServers method with pagination
serverRecords, nextCursor, err := s.db.List(ctx, nil, cursor, limit)
if err != nil {
return nil, "", err
@ -58,7 +58,6 @@ func (s *registryServiceImpl) GetByID(id string) (*apiv0.ServerJSON, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Use the database's GetByID method to retrieve the server record
serverRecord, err := s.db.GetByID(ctx, id)
if err != nil {
return nil, err
@ -87,7 +86,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
return nil, err
}
existingServerVersions, _, err := s.db.List(ctx, map[string]any{"name": serverJSON.Name}, "", maxServerVersionsPerServer)
filter := &database.ServerFilter{Name: &serverJSON.Name}
existingServerVersions, _, err := s.db.List(ctx, filter, "", maxServerVersionsPerServer)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, err
}
@ -121,8 +121,16 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
) > 0
}
// Create registry metadata with service-determined values
registryMetadata := apiv0.RegistryExtensions{
// Create complete server with metadata
server := serverJSON // Copy the input
// Initialize meta if not present
if server.Meta == nil {
server.Meta = &apiv0.ServerMeta{}
}
// Set registry metadata
server.Meta.IOModelContextProtocolRegistry = &apiv0.RegistryExtensions{
ID: uuid.New().String(),
PublishedAt: publishTime,
UpdatedAt: publishTime,
@ -130,14 +138,8 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
ReleaseDate: publishTime.Format(time.RFC3339),
}
// Extract publisher extensions from _meta.publisher
publisherExtensions := make(map[string]interface{})
if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil {
publisherExtensions = serverJSON.Meta.Publisher
}
// Publish to database with the registry metadata (still using old format internally)
serverRecord, err := s.db.Publish(ctx, serverJSON, publisherExtensions, registryMetadata)
// Create server in database
serverRecord, err := s.db.CreateServer(ctx, &server)
if err != nil {
return nil, err
}
@ -149,7 +151,10 @@ func (s *registryServiceImpl) Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON,
existingLatestID = existingLatest.Meta.IOModelContextProtocolRegistry.ID
}
if existingLatestID != "" {
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
// Update the existing server to set is_latest = false
existingLatest.Meta.IOModelContextProtocolRegistry.IsLatest = false
existingLatest.Meta.IOModelContextProtocolRegistry.UpdatedAt = time.Now()
if _, err := s.db.UpdateServer(ctx, existingLatestID, existingLatest); err != nil {
return nil, err
}
}
@ -164,9 +169,7 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
// Check each remote URL in the new server for conflicts
for _, remote := range serverDetail.Remotes {
// Use filter to find servers with this remote URL
filter := map[string]any{
"remote_url": remote.URL,
}
filter := &database.ServerFilter{RemoteURL: &remote.URL}
conflictingServers, _, err := s.db.List(ctx, filter, "", 1000)
if err != nil {
@ -187,8 +190,8 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
// getCurrentLatestVersion finds the current latest version from existing server versions
func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerJSON) *apiv0.ServerJSON {
for _, server := range existingServerVersions {
if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil &&
server.Meta.IOModelContextProtocolRegistry.IsLatest {
if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil &&
server.Meta.IOModelContextProtocolRegistry.IsLatest {
return server
}
}
@ -212,14 +215,8 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv
return nil, err
}
// Extract publisher extensions from _meta.publisher
publisherExtensions := make(map[string]interface{})
if serverJSON.Meta != nil && serverJSON.Meta.Publisher != nil {
publisherExtensions = serverJSON.Meta.Publisher
}
// Update server in database
serverRecord, err := s.db.UpdateServer(ctx, id, serverJSON, publisherExtensions)
serverRecord, err := s.db.UpdateServer(ctx, id, &serverJSON)
if err != nil {
return nil, err
}
@ -227,4 +224,3 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv
// Return the server record directly
return serverRecord, nil
}

View file

@ -37,9 +37,16 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
},
}
memDB := database.NewMemoryDB(existingServers)
memDB := database.NewMemoryDB()
service := NewRegistryServiceWithDB(memDB)
for _, server := range existingServers {
_, err := service.Publish(*server)
if err != nil {
t.Fatalf("failed to publish server: %v", err)
}
}
tests := []struct {
name string
serverDetail apiv0.ServerJSON

View file

@ -9,7 +9,6 @@ const (
StatusDeleted Status = "deleted"
)
// Package represents a package configuration
type Package struct {
// RegistryType indicates how to download packages (e.g., "npm", "pypi", "oci", "mcpb")
@ -92,5 +91,5 @@ type Argument struct {
// VersionDetail represents the version details of a server (pure MCP spec, no registry metadata)
type VersionDetail struct {
Version string `json:"version"`
Version string `json:"version" minLength:"1"`
}

View file

@ -217,7 +217,7 @@ func findServerIDByName(serverName string) (string, error) {
// Find the server with matching name
for _, server := range serverList.Servers {
if server.Name == serverName {
if server.Name == serverName && server.Meta.IOModelContextProtocolRegistry.IsLatest {
return server.Meta.IOModelContextProtocolRegistry.ID, nil
}
}