diff --git a/internal/service/registry_service.go b/internal/service/registry_service.go index 16a910002..a77478e53 100644 --- a/internal/service/registry_service.go +++ b/internal/service/registry_service.go @@ -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 @@ -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 } @@ -67,6 +86,7 @@ func (s *registryServiceImpl) GetServerByName(ctx context.Context, serverName st return nil, err } + normalizeReadSchema(serverRecord) return serverRecord, nil } @@ -77,6 +97,7 @@ func (s *registryServiceImpl) GetServerByNameAndVersion(ctx context.Context, ser return nil, err } + normalizeReadSchema(serverRecord) return serverRecord, nil } @@ -87,6 +108,10 @@ func (s *registryServiceImpl) GetAllVersionsByServerName(ctx context.Context, se return nil, err } + for _, serverRecord := range serverRecords { + normalizeReadSchema(serverRecord) + } + return serverRecords, nil } diff --git a/internal/service/registry_service_test.go b/internal/service/registry_service_test.go index 05ec8477a..ea9bf7c2b 100644 --- a/internal/service/registry_service_test.go +++ b/internal/service/registry_service_test.go @@ -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