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
2 changed files with 19375 additions and 19670 deletions

View file

@ -11,7 +11,7 @@ import (
"github.com/modelcontextprotocol/registry/pkg/model"
)
func ValidateServerJSON(serverJSON *model.ServerJSON) error {
func ValidateServerJSON(serverJSON *apiv0.ServerJSON) error {
// Validate server name exists and format
if _, err := parseServerName(*serverJSON); err != nil {
return err
@ -194,38 +194,45 @@ func validatePackage(pkg *model.Package) error {
}
// ValidatePublishRequest validates a complete publish request including extensions
func ValidatePublishRequest(req apiv0.PublishRequest) error {
// Validate publisher extensions
func ValidatePublishRequest(req apiv0.ServerJSON) error {
// Validate publisher extensions in _meta
if err := validatePublisherExtensions(req); err != nil {
return err
}
// Validate the server detail (includes all nested validation)
if err := ValidateServerJSON(&req.Server); err != nil {
if err := ValidateServerJSON(&req); err != nil {
return err
}
return nil
}
func validatePublisherExtensions(req apiv0.PublishRequest) error {
func validatePublisherExtensions(req apiv0.ServerJSON) error {
const maxExtensionSize = 4 * 1024 // 4KB limit
// Check size limit for x-publisher extension
if req.XPublisher != nil {
extensionsJSON, err := json.Marshal(req.XPublisher)
// Check size limit for _meta.publisher extension
if req.Meta != nil && req.Meta.Publisher != nil {
extensionsJSON, err := json.Marshal(req.Meta.Publisher)
if err != nil {
return fmt.Errorf("failed to marshal x-publisher extension: %w", err)
return fmt.Errorf("failed to marshal _meta.publisher extension: %w", err)
}
if len(extensionsJSON) > maxExtensionSize {
return fmt.Errorf("x-publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON))
return fmt.Errorf("_meta.publisher extension exceeds 4KB limit (%d bytes)", len(extensionsJSON))
}
}
if req.Meta != nil {
// Validate that only "publisher" is allowed in _meta during publish (no registry metadata should be present)
if req.Meta.IOModelContextProtocolRegistry != nil {
return fmt.Errorf("registry metadata '_meta.io.modelcontextprotocol.registry' is not allowed during publish")
}
}
return nil
}
func parseServerName(serverJSON model.ServerJSON) (string, error) {
func parseServerName(serverJSON apiv0.ServerJSON) (string, error) {
name := serverJSON.Name
if name == "" {
return "", fmt.Errorf("server name is required and must be a string")
@ -245,7 +252,7 @@ func parseServerName(serverJSON model.ServerJSON) (string, error) {
}
// validateRemoteNamespaceMatch validates that remote URLs match the reverse-DNS namespace
func validateRemoteNamespaceMatch(serverJSON model.ServerJSON) error {
func validateRemoteNamespaceMatch(serverJSON apiv0.ServerJSON) error {
namespace := serverJSON.Name
for _, remote := range serverJSON.Remotes {

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
)
@ -12,12 +13,12 @@ import (
func TestValidate(t *testing.T) {
tests := []struct {
name string
serverDetail model.ServerJSON
serverDetail apiv0.ServerJSON
expectedError string
}{
{
name: "valid server detail with all fields",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -45,7 +46,7 @@ func TestValidate(t *testing.T) {
},
{
name: "server with invalid repository source",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -60,7 +61,7 @@ func TestValidate(t *testing.T) {
},
{
name: "server with invalid GitHub URL format",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -75,7 +76,7 @@ func TestValidate(t *testing.T) {
},
{
name: "server with invalid GitLab URL format",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -90,7 +91,7 @@ func TestValidate(t *testing.T) {
},
{
name: "package with spaces in name",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -112,7 +113,7 @@ func TestValidate(t *testing.T) {
},
{
name: "multiple packages with one invalid",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -139,7 +140,7 @@ func TestValidate(t *testing.T) {
},
{
name: "remote with invalid URL",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -159,7 +160,7 @@ func TestValidate(t *testing.T) {
},
{
name: "remote with missing scheme",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -179,7 +180,7 @@ func TestValidate(t *testing.T) {
},
{
name: "multiple remotes with one invalid",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -202,7 +203,7 @@ func TestValidate(t *testing.T) {
},
{
name: "server detail with nil packages and remotes",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -219,7 +220,7 @@ func TestValidate(t *testing.T) {
},
{
name: "server detail with empty packages and remotes slices",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -253,13 +254,13 @@ func TestValidate(t *testing.T) {
func TestValidate_RemoteNamespaceMatch(t *testing.T) {
tests := []struct {
name string
serverDetail model.ServerJSON
serverDetail apiv0.ServerJSON
expectError bool
errorMsg string
}{
{
name: "valid match - example.com domain",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Remotes: []model.Remote{
{URL: "https://example.com/mcp"},
@ -269,7 +270,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "valid match - subdomain mcp.example.com",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Remotes: []model.Remote{
{URL: "https://mcp.example.com/endpoint"},
@ -279,7 +280,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "valid match - api subdomain",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/api-server",
Remotes: []model.Remote{
{URL: "https://api.example.com/mcp"},
@ -289,7 +290,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "invalid - wrong domain",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Remotes: []model.Remote{
{URL: "https://google.com/mcp"},
@ -300,7 +301,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "invalid - different domain entirely",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.microsoft/server",
Remotes: []model.Remote{
{URL: "https://api.github.com/endpoint"},
@ -311,7 +312,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "localhost URLs allowed with any namespace",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test-server",
Remotes: []model.Remote{
{URL: "http://localhost:3000/sse"},
@ -321,7 +322,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "invalid URL format",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test",
Remotes: []model.Remote{
{URL: "not-a-valid-url"},
@ -332,7 +333,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "empty remotes array",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/test",
Remotes: []model.Remote{},
},
@ -340,7 +341,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "multiple valid remotes - different subdomains",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/server",
Remotes: []model.Remote{
{URL: "https://api.example.com/sse"},
@ -351,7 +352,7 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
},
{
name: "one valid, one invalid remote",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/server",
Remotes: []model.Remote{
{URL: "https://example.com/sse"},
@ -380,27 +381,27 @@ func TestValidate_RemoteNamespaceMatch(t *testing.T) {
func TestValidate_ServerNameFormat(t *testing.T) {
tests := []struct {
name string
serverDetail model.ServerJSON
serverDetail apiv0.ServerJSON
expectError bool
errorMsg string
}{
{
name: "valid namespace/name format",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example.api/server",
},
expectError: false,
},
{
name: "valid complex namespace",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.github.microsoft.azure/webapp-server",
},
expectError: false,
},
{
name: "empty server name",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "",
},
expectError: true,
@ -408,7 +409,7 @@ func TestValidate_ServerNameFormat(t *testing.T) {
},
{
name: "missing slash separator",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example.server",
},
expectError: true,
@ -416,7 +417,7 @@ func TestValidate_ServerNameFormat(t *testing.T) {
},
{
name: "empty namespace part",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "/server-name",
},
expectError: true,
@ -424,7 +425,7 @@ func TestValidate_ServerNameFormat(t *testing.T) {
},
{
name: "empty name part",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/",
},
expectError: true,
@ -432,7 +433,7 @@ func TestValidate_ServerNameFormat(t *testing.T) {
},
{
name: "multiple slashes - uses first as separator",
serverDetail: model.ServerJSON{
serverDetail: apiv0.ServerJSON{
Name: "com.example/server/path",
},
expectError: false,
@ -647,8 +648,8 @@ func TestValidateArgument_ValidValueFields(t *testing.T) {
}
// Helper function to create a valid server with a specific argument for testing
func createValidServerWithArgument(arg model.Argument) model.ServerJSON {
return model.ServerJSON{
func createValidServerWithArgument(arg model.Argument) apiv0.ServerJSON {
return apiv0.ServerJSON{
Name: "com.example/test-server",
Description: "A test server",
Repository: model.Repository{
@ -661,9 +662,9 @@ func createValidServerWithArgument(arg model.Argument) model.ServerJSON {
},
Packages: []model.Package{
{
Identifier: "test-package",
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
Identifier: "test-package",
RegistryType: "npm",
RegistryBaseURL: "https://registry.npmjs.org",
RuntimeArguments: []model.Argument{arg},
},
},