Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions internal/service/registry_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ func NewRegistryService(db database.Database, cfg *config.Config) RegistryServic
}
}

// normalizeReadSchema rewrites a stored server's $schema to the current schema URL.
//
// Server records are persisted as JSONB of the apiv0.ServerJSON struct and are always
// re-serialized into the current struct shape on read, but the $schema string is carried
// verbatim from publish time. Because every schema revision has been additive or relaxing
// (docs/reference/server-json/CHANGELOG.md — no field made newly required), an entry
// published under an older schema still conforms to the current one. Advertising the stale
// publish-time $schema makes strict clients (e.g. VSCode) fail validation, so we normalize
// it on the read path. See issue #783.
func normalizeReadSchema(resp *apiv0.ServerResponse) {
if resp != nil {
resp.Server.Schema = model.CurrentSchemaURL
}
}

// ListServers returns registry entries with cursor-based pagination and optional filtering
func (s *registryServiceImpl) ListServers(ctx context.Context, filter *database.ServerFilter, cursor string, limit int) ([]*apiv0.ServerResponse, string, error) {
// If limit is not set or negative, use a default limit
Expand All @@ -57,6 +72,10 @@ func (s *registryServiceImpl) ListServers(ctx context.Context, filter *database.
return nil, "", err
}

for _, serverRecord := range serverRecords {
normalizeReadSchema(serverRecord)
}

return serverRecords, nextCursor, nil
}

Expand All @@ -67,6 +86,7 @@ func (s *registryServiceImpl) GetServerByName(ctx context.Context, serverName st
return nil, err
}

normalizeReadSchema(serverRecord)
return serverRecord, nil
}

Expand All @@ -77,6 +97,7 @@ func (s *registryServiceImpl) GetServerByNameAndVersion(ctx context.Context, ser
return nil, err
}

normalizeReadSchema(serverRecord)
return serverRecord, nil
}

Expand All @@ -87,6 +108,10 @@ func (s *registryServiceImpl) GetAllVersionsByServerName(ctx context.Context, se
return nil, err
}

for _, serverRecord := range serverRecords {
normalizeReadSchema(serverRecord)
}

return serverRecords, nil
}

Expand Down
68 changes: 68 additions & 0 deletions internal/service/registry_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,74 @@ func TestRecalculateLatest_RestoringHigherVersionPromotesIt(t *testing.T) {
assert.True(t, latest.Meta.Official.IsLatest)
}

// TestReadNormalizesLegacySchemaVersion covers issue #783: servers published under an
// older server.json schema are stored with that publish-time $schema, but the registry
// re-serializes every record into the current struct shape on read. Serving the stale
// $schema makes strict clients fail validation, so all read paths must advertise the
// current schema URL.
func TestReadNormalizesLegacySchemaVersion(t *testing.T) {
ctx := context.Background()
testDB := database.NewTestDB(t)
service := NewRegistryService(testDB, &config.Config{EnableRegistryValidation: false})

// A pre-current schema version (real value observed on the live API in issue #783).
const legacySchemaURL = "https://static.modelcontextprotocol.io/schemas/2025-09-16/server.schema.json"
require.NotEqual(t, model.CurrentSchemaURL, legacySchemaURL, "legacy fixture must differ from current schema")

now := time.Now()
legacy := &apiv0.ServerJSON{
Schema: legacySchemaURL,
Name: "com.example/legacy-schema-server",
Description: "Published under an older schema version",
Version: "1.0.0",
}

// Seed directly through the database so the stored $schema is the legacy value
// (the publish path enforces the current schema, so it cannot create this state).
_, err := testDB.CreateServer(ctx, nil, legacy, &apiv0.RegistryExtensions{
Status: model.StatusActive,
StatusChangedAt: now,
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
})
require.NoError(t, err, "failed to seed legacy-schema server")

t.Run("GetServerByName", func(t *testing.T) {
got, err := service.GetServerByName(ctx, legacy.Name, false)
require.NoError(t, err)
assert.Equal(t, model.CurrentSchemaURL, got.Server.Schema)
})

t.Run("GetServerByNameAndVersion", func(t *testing.T) {
got, err := service.GetServerByNameAndVersion(ctx, legacy.Name, legacy.Version, false)
require.NoError(t, err)
assert.Equal(t, model.CurrentSchemaURL, got.Server.Schema)
})

t.Run("GetAllVersionsByServerName", func(t *testing.T) {
got, err := service.GetAllVersionsByServerName(ctx, legacy.Name, false)
require.NoError(t, err)
require.NotEmpty(t, got)
for _, s := range got {
assert.Equal(t, model.CurrentSchemaURL, s.Server.Schema)
}
})

t.Run("ListServers", func(t *testing.T) {
got, _, err := service.ListServers(ctx, nil, "", 100)
require.NoError(t, err)
var found bool
for _, s := range got {
if s.Server.Name == legacy.Name {
found = true
assert.Equal(t, model.CurrentSchemaURL, s.Server.Schema)
}
}
require.True(t, found, "seeded server should appear in ListServers")
})
}

// Helper functions
func stringPtr(s string) *string {
return &s
Expand Down
Loading