Implement flattened _meta format for server extensions (#331)

## Summary

This PR implements the flattened `_meta` format for server extensions as
discussed in #284, replacing the confusing wrapper format with a clean
single-file approach.

**Before (wrapper format):**
```json
{
  "server": { "name": "my-server", "description": "..." },
  "x-publisher": { "build_info": "..." },
  "x-io.modelcontextprotocol.registry": { "id": "...", "published_at": "..." }
}
```

**After (flattened format):**
```json
{
  "name": "my-server",
  "description": "...",
  "_meta": {
    "publisher": { "build_info": "..." },
    "io.modelcontextprotocol.registry": { "id": "...", "published_at": "..." }
  }
}
```

## Key Changes

- **Flattened Format**: Single `ServerJSON` file with structured `_meta`
field
- **Better DX**: Publishers maintain one file instead of separate
server.json + extensions.json
- **MCP Compliance**: Uses standard `_meta` mechanism instead of `x-*`
prefixes
- **Simplified Database**: PostgreSQL now uses simple `id, value` table
structure
- **Type Safety**: Structured `ServerMeta` with known extension fields

## Database Migration

Added migration `003_simplify_to_key_value.sql` that drops the complex
relational structure and creates a simple key-value table. This
significantly simplifies database management.

## Testing

- All core tests passing (validators, service layer)
- Schema validation working for all 10 examples
- Seed data successfully migrated (396 entries)
- Publishing functionality verified

Fixes #284
This commit is contained in:
adam jones 2025-09-01 19:27:23 +01:00 • committed by GitHub
commit 218b0b422d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 19375 additions and 19670 deletions

View file

@ -19,7 +19,7 @@ type fakeRegistryService struct {
// NewFakeRegistryService creates a new fake registry service with pre-populated data
func NewFakeRegistryService() RegistryService {
// Sample registry entries with updated model structure using ServerJSON
serverDetails := []*model.ServerJSON{
serverDetails := []*apiv0.ServerJSON{
{
Name: "bluegreen/mcp-server",
Description: "A dummy MCP registry for testing",
@ -59,7 +59,7 @@ func NewFakeRegistryService() RegistryService {
}
// Create a new in-memory database using registry metadata IDs
serverDetailMap := make(map[string]*model.ServerJSON)
serverDetailMap := make(map[string]*apiv0.ServerJSON)
for _, entry := range serverDetails {
registryID := uuid.New().String() // Generate registry metadata ID
serverDetailMap[registryID] = entry
@ -71,7 +71,7 @@ func NewFakeRegistryService() RegistryService {
}
// List retrieves servers with extension wrapper format
func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv0.ServerRecord, string, error) {
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()
@ -83,7 +83,7 @@ func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv0.ServerReco
}
// Return ServerRecords directly (they're now the same as ServerResponse)
result := make([]apiv0.ServerRecord, len(serverRecords))
result := make([]apiv0.ServerJSON, len(serverRecords))
for i, record := range serverRecords {
result[i] = *record
}
@ -92,7 +92,7 @@ func (s *fakeRegistryService) List(cursor string, limit int) ([]apiv0.ServerReco
}
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *fakeRegistryService) GetByID(id string) (*apiv0.ServerRecord, error) {
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()
@ -108,7 +108,7 @@ func (s *fakeRegistryService) GetByID(id string) (*apiv0.ServerRecord, error) {
}
// Publish publishes a server with separated extensions
func (s *fakeRegistryService) Publish(req apiv0.PublishRequest) (*apiv0.ServerRecord, error) {
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()
@ -118,10 +118,10 @@ func (s *fakeRegistryService) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
return nil, err
}
// Use publisher extensions directly from request
publisherExtensions := req.XPublisher
if publisherExtensions == nil {
publisherExtensions = make(map[string]interface{})
// 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)
@ -135,7 +135,7 @@ func (s *fakeRegistryService) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
}
// Publish to database
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
serverRecord, err := s.db.Publish(ctx, req, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}
@ -145,7 +145,7 @@ func (s *fakeRegistryService) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
}
// EditServer updates an existing server with new details (admin operation)
func (s *fakeRegistryService) EditServer(id string, req apiv0.PublishRequest) (*apiv0.ServerRecord, error) {
func (s *fakeRegistryService) EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -154,14 +154,14 @@ func (s *fakeRegistryService) EditServer(id string, req apiv0.PublishRequest) (*
return nil, err
}
// Use publisher extensions directly from request
publisherExtensions := req.XPublisher
if publisherExtensions == nil {
publisherExtensions = make(map[string]interface{})
// 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.Server, publisherExtensions)
serverRecord, err := s.db.UpdateServer(ctx, id, req, publisherExtensions)
if err != nil {
return nil, err
}

View file

@ -10,7 +10,6 @@ import (
"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"
)
const maxServerVersionsPerServer = 10000
@ -27,8 +26,8 @@ func NewRegistryServiceWithDB(db database.Database) RegistryService {
}
}
// List returns registry entries with cursor-based pagination in extension wrapper format
func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerRecord, string, error) {
// List returns registry entries with cursor-based pagination in flattened format
func (s *registryServiceImpl) 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()
@ -44,8 +43,8 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerReco
return nil, "", err
}
// Return ServerRecords directly (they're now the same as ServerResponse)
result := make([]apiv0.ServerRecord, len(serverRecords))
// Return ServerJSONs directly
result := make([]apiv0.ServerJSON, len(serverRecords))
for i, record := range serverRecords {
result[i] = *record
}
@ -53,8 +52,8 @@ func (s *registryServiceImpl) List(cursor string, limit int) ([]apiv0.ServerReco
return result, nextCursor, nil
}
// GetByID retrieves a specific server by its registry metadata ID in extension wrapper format
func (s *registryServiceImpl) GetByID(id string) (*apiv0.ServerRecord, error) {
// GetByID retrieves a specific server by its registry metadata ID in flattened format
func (s *registryServiceImpl) 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()
@ -65,12 +64,12 @@ func (s *registryServiceImpl) GetByID(id string) (*apiv0.ServerRecord, error) {
return nil, err
}
// Return ServerRecord directly (it's now the same as ServerResponse)
// Return the server record directly
return serverRecord, nil
}
// Publish publishes a server with separated extensions
func (s *registryServiceImpl) Publish(req apiv0.PublishRequest) (*apiv0.ServerRecord, error) {
// Publish publishes a server with flattened _meta extensions
func (s *registryServiceImpl) 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()
@ -81,13 +80,14 @@ func (s *registryServiceImpl) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
}
publishTime := time.Now()
serverJSON := req
// Check for duplicate remote URLs
if err := s.validateNoDuplicateRemoteURLs(ctx, req.Server); err != nil {
if err := s.validateNoDuplicateRemoteURLs(ctx, serverJSON); err != nil {
return nil, err
}
existingServerVersions, _, err := s.db.List(ctx, map[string]any{"name": req.Server.Name}, "", maxServerVersionsPerServer)
existingServerVersions, _, err := s.db.List(ctx, map[string]any{"name": serverJSON.Name}, "", maxServerVersionsPerServer)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, err
}
@ -99,8 +99,8 @@ func (s *registryServiceImpl) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
// Check this isn't a duplicate version
for _, server := range existingServerVersions {
existingVersion := server.Server.VersionDetail.Version
if existingVersion == req.Server.VersionDetail.Version {
existingVersion := server.VersionDetail.Version
if existingVersion == serverJSON.VersionDetail.Version {
return nil, database.ErrInvalidVersion
}
}
@ -109,11 +109,15 @@ func (s *registryServiceImpl) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
existingLatest := s.getCurrentLatestVersion(existingServerVersions)
isNewLatest := true
if existingLatest != nil {
var existingPublishedAt time.Time
if existingLatest.Meta != nil && existingLatest.Meta.IOModelContextProtocolRegistry != nil {
existingPublishedAt = existingLatest.Meta.IOModelContextProtocolRegistry.PublishedAt
}
isNewLatest = CompareVersions(
req.Server.VersionDetail.Version,
existingLatest.Server.VersionDetail.Version,
serverJSON.VersionDetail.Version,
existingLatest.VersionDetail.Version,
publishTime,
existingLatest.XIOModelContextProtocolRegistry.PublishedAt,
existingPublishedAt,
) > 0
}
@ -126,31 +130,37 @@ func (s *registryServiceImpl) Publish(req apiv0.PublishRequest) (*apiv0.ServerRe
ReleaseDate: publishTime.Format(time.RFC3339),
}
// Use publisher extensions directly from request
publisherExtensions := req.XPublisher
if publisherExtensions == nil {
publisherExtensions = make(map[string]interface{})
// 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
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
// Publish to database with the registry metadata (still using old format internally)
serverRecord, err := s.db.Publish(ctx, serverJSON, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}
// Mark previous latest as no longer latest
if isNewLatest && existingLatest != nil {
if err := s.db.UpdateLatestFlag(ctx, existingLatest.XIOModelContextProtocolRegistry.ID, false); err != nil {
return nil, err
var existingLatestID string
if existingLatest.Meta != nil && existingLatest.Meta.IOModelContextProtocolRegistry != nil {
existingLatestID = existingLatest.Meta.IOModelContextProtocolRegistry.ID
}
if existingLatestID != "" {
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
return nil, err
}
}
}
// Return ServerRecord directly (it's now the same as ServerResponse)
// Return the server record directly
return serverRecord, nil
}
// validateNoDuplicateRemoteURLs checks that no other server is using the same remote URLs
func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, serverDetail model.ServerJSON) error {
func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context, serverDetail apiv0.ServerJSON) error {
// Check each remote URL in the new server for conflicts
for _, remote := range serverDetail.Remotes {
// Use filter to find servers with this remote URL
@ -165,8 +175,8 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
// Check if any conflicting server has a different name
for _, conflictingServer := range conflictingServers {
if conflictingServer.Server.Name != serverDetail.Name {
return fmt.Errorf("remote URL %s is already used by server %s", remote.URL, conflictingServer.Server.Name)
if conflictingServer.Name != serverDetail.Name {
return fmt.Errorf("remote URL %s is already used by server %s", remote.URL, conflictingServer.Name)
}
}
}
@ -175,9 +185,10 @@ func (s *registryServiceImpl) validateNoDuplicateRemoteURLs(ctx context.Context,
}
// getCurrentLatestVersion finds the current latest version from existing server versions
func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerRecord) *apiv0.ServerRecord {
func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*apiv0.ServerJSON) *apiv0.ServerJSON {
for _, server := range existingServerVersions {
if server.XIOModelContextProtocolRegistry.IsLatest {
if server.Meta != nil && server.Meta.IOModelContextProtocolRegistry != nil &&
server.Meta.IOModelContextProtocolRegistry.IsLatest {
return server
}
}
@ -185,7 +196,7 @@ func (s *registryServiceImpl) getCurrentLatestVersion(existingServerVersions []*
}
// EditServer updates an existing server with new details (admin operation)
func (s *registryServiceImpl) EditServer(id string, req apiv0.PublishRequest) (*apiv0.ServerRecord, error) {
func (s *registryServiceImpl) EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -194,23 +205,26 @@ func (s *registryServiceImpl) EditServer(id string, req apiv0.PublishRequest) (*
return nil, err
}
serverJSON := req
// Check for duplicate remote URLs
if err := s.validateNoDuplicateRemoteURLs(ctx, req.Server); err != nil {
if err := s.validateNoDuplicateRemoteURLs(ctx, serverJSON); err != nil {
return nil, err
}
// Use publisher extensions directly from request
publisherExtensions := req.XPublisher
if publisherExtensions == nil {
publisherExtensions = make(map[string]interface{})
// 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, req.Server, publisherExtensions)
serverRecord, err := s.db.UpdateServer(ctx, id, serverJSON, publisherExtensions)
if err != nil {
return nil, err
}
// Return ServerRecord directly (it's now the same as ServerResponse)
// Return the server record directly
return serverRecord, nil
}

View file

@ -6,13 +6,14 @@ import (
"testing"
"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 TestValidateNoDuplicateRemoteURLs(t *testing.T) {
// Create test data
existingServers := map[string]*model.ServerJSON{
existingServers := map[string]*apiv0.ServerJSON{
"existing1": {
Name: "com.example/existing-server",
Description: "An existing server",
@ -41,13 +42,13 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
tests := []struct {
name string
serverDetail model.ServerJSON
serverDetail apiv0.ServerJSON
expectError bool
errorMsg string
}{
{
name: "no remote URLs - should pass",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/new-server",
Description: "A new server with no remotes",
VersionDetail: model.VersionDetail{
@ -59,7 +60,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
},
{
name: "new unique remote URLs - should pass",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/new-server",
Description: "A new server",
VersionDetail: model.VersionDetail{
@ -74,7 +75,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
},
{
name: "duplicate remote URL - should fail",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/new-server",
Description: "A new server with duplicate URL",
VersionDetail: model.VersionDetail{
@ -89,7 +90,7 @@ func TestValidateNoDuplicateRemoteURLs(t *testing.T) {
},
{
name: "updating same server with same URLs - should pass",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/existing-server", // Same name as existing
Description: "Updated existing server",
VersionDetail: model.VersionDetail{

View file

@ -1,15 +1,17 @@
package service
import apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
import (
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
// RegistryService defines the interface for registry operations
type RegistryService interface {
// List retrieves servers with unified format
List(cursor string, limit int) ([]apiv0.ServerRecord, string, error)
// GetByID retrieves a single server by registry metadata ID with unified format
GetByID(id string) (*apiv0.ServerRecord, error)
// Publish publishes a server with separated extensions
Publish(req apiv0.PublishRequest) (*apiv0.ServerRecord, error)
// EditServer updates an existing server with new details
EditServer(id string, req apiv0.PublishRequest) (*apiv0.ServerRecord, error)
// Retrieve all servers
List(cursor string, limit int) ([]apiv0.ServerJSON, string, error)
// Retrieve a single server by registry metadata ID
GetByID(id string) (*apiv0.ServerJSON, error)
// Publish a server
Publish(req apiv0.ServerJSON) (*apiv0.ServerJSON, error)
// Update an existing server
EditServer(id string, req apiv0.ServerJSON) (*apiv0.ServerJSON, error)
}