Deduplicate server JSON structs between publisher and registry (#318)

<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->

The following PR eliminates duplicate struct definitions by migrating
the publisher tool to use canonical types from pkg/model, creating a
single source of truth across the codebase. The publisher tool
previously had its own copies of structs that were duplicated, ie.
ServerJSON, Package, Repository, VersionDetail, EnvironmentVariable,
RuntimeArgument and a few others which was not optimal


**Changes in the PR:**
- Refactored the structs to single canonical types located at pkg/model
- Refactor the implementation to use/build canonical structures directly
  - Remove all duplicate definitions (5 structs eliminated)
- Publisher now uses model.ServerJSON, model.Package, model.Repository,
etc.
- Renamed some of the structs to better reflect their use case, i.e.
ServerJSON instead of ServerDetail
- Fixes an issue where the Remote property was missing from the previous
publisher copy of the Server

**Benefits:**
  - Single source of truth - Schema changes in one place
  - Type safety - Compiler catches mismatches
  - Enhanced validation - Field constraints and bson tags
  - Zero breaking changes - Same CLI interface and JSON output
  - Future-proof - New canonical fields automatically available

Supersedes: #217 and #240

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

Locally

## Breaking Changes
<!-- Will users need to update their code or configurations? -->
No

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

---------

Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
This commit is contained in:
Radoslav Dimitrov 2025-08-28 16:58:15 +03:00 • committed by GitHub
commit 099a2d702e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -10,7 +10,8 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/registry/internal/model"
apiv1 "github.com/modelcontextprotocol/registry/pkg/api/v1"
"github.com/modelcontextprotocol/registry/pkg/model"
)
// PostgreSQL is an implementation of the Database interface using PostgreSQL
@ -47,7 +48,7 @@ func (db *PostgreSQL) List(
filter map[string]any,
cursor string,
limit int,
) ([]*model.ServerRecord, string, error) {
) ([]*apiv1.ServerRecord, string, error) {
if limit <= 0 {
limit = 10
}
@ -112,26 +113,26 @@ func (db *PostgreSQL) List(
}
defer rows.Close()
var results []*model.ServerRecord
var results []*apiv1.ServerRecord
for rows.Next() {
var record model.ServerRecord
var record apiv1.ServerRecord
var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte
var publishedAt, updatedAt, releaseDate time.Time
err := rows.Scan(
// Server fields
&record.ServerJSON.Name,
&record.ServerJSON.Description,
&record.ServerJSON.Status,
&record.Server.Name,
&record.Server.Description,
&record.Server.Status,
&repositoryJSON,
&record.ServerJSON.VersionDetail.Version,
&record.Server.VersionDetail.Version,
&packagesJSON,
&remotesJSON,
// Registry metadata fields
&record.RegistryMetadata.ID,
&record.XIOModelContextProtocolRegistry.ID,
&publishedAt,
&updatedAt,
&record.RegistryMetadata.IsLatest,
&record.XIOModelContextProtocolRegistry.IsLatest,
&releaseDate,
&publisherExtensionsJSON,
)
@ -145,9 +146,9 @@ func (db *PostgreSQL) List(
}
// Set registry metadata timestamps
record.RegistryMetadata.PublishedAt = publishedAt
record.RegistryMetadata.UpdatedAt = updatedAt
record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339)
record.XIOModelContextProtocolRegistry.PublishedAt = publishedAt
record.XIOModelContextProtocolRegistry.UpdatedAt = updatedAt
record.XIOModelContextProtocolRegistry.ReleaseDate = releaseDate.Format(time.RFC3339)
results = append(results, &record)
}
@ -159,45 +160,45 @@ func (db *PostgreSQL) List(
// Determine next cursor using registry metadata ID
nextCursor := ""
if len(results) > 0 && len(results) >= limit {
nextCursor = results[len(results)-1].RegistryMetadata.ID
nextCursor = results[len(results)-1].XIOModelContextProtocolRegistry.ID
}
return results, nextCursor, nil
}
// parseJSONFields parses JSON fields for a server record
func parseJSONFields(record *model.ServerRecord, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte) error {
func parseJSONFields(record *apiv1.ServerRecord, repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte) error {
if len(repositoryJSON) > 0 {
if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil {
if err := json.Unmarshal(repositoryJSON, &record.Server.Repository); err != nil {
return fmt.Errorf("failed to unmarshal repository: %w", err)
}
}
if len(packagesJSON) > 0 {
if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil {
if err := json.Unmarshal(packagesJSON, &record.Server.Packages); err != nil {
return fmt.Errorf("failed to unmarshal packages: %w", err)
}
}
if len(remotesJSON) > 0 {
if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil {
if err := json.Unmarshal(remotesJSON, &record.Server.Remotes); err != nil {
return fmt.Errorf("failed to unmarshal remotes: %w", err)
}
}
if len(publisherExtensionsJSON) > 0 {
if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil {
if err := json.Unmarshal(publisherExtensionsJSON, &record.XPublisher); err != nil {
return fmt.Errorf("failed to unmarshal publisher extensions: %w", err)
}
} else {
record.PublisherExtensions = make(map[string]interface{})
record.XPublisher = make(map[string]interface{})
}
return nil
}
// GetByID retrieves a single ServerRecord by its registry metadata ID
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerRecord, error) {
func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*apiv1.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -211,24 +212,24 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco
WHERE se.id = $1
`
var record model.ServerRecord
var record apiv1.ServerRecord
var repositoryJSON, packagesJSON, remotesJSON, publisherExtensionsJSON []byte
var publishedAt, updatedAt, releaseDate time.Time
err := db.conn.QueryRow(ctx, query, id).Scan(
// Server fields
&record.ServerJSON.Name,
&record.ServerJSON.Description,
&record.ServerJSON.Status,
&record.Server.Name,
&record.Server.Description,
&record.Server.Status,
&repositoryJSON,
&record.ServerJSON.VersionDetail.Version,
&record.Server.VersionDetail.Version,
&packagesJSON,
&remotesJSON,
// Registry metadata fields
&record.RegistryMetadata.ID,
&record.XIOModelContextProtocolRegistry.ID,
&publishedAt,
&updatedAt,
&record.RegistryMetadata.IsLatest,
&record.XIOModelContextProtocolRegistry.IsLatest,
&releaseDate,
&publisherExtensionsJSON,
)
@ -242,42 +243,42 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco
// Parse JSON fields
if len(repositoryJSON) > 0 {
if err := json.Unmarshal(repositoryJSON, &record.ServerJSON.Repository); err != nil {
if err := json.Unmarshal(repositoryJSON, &record.Server.Repository); err != nil {
return nil, fmt.Errorf("failed to unmarshal repository: %w", err)
}
}
if len(packagesJSON) > 0 {
if err := json.Unmarshal(packagesJSON, &record.ServerJSON.Packages); err != nil {
if err := json.Unmarshal(packagesJSON, &record.Server.Packages); err != nil {
return nil, fmt.Errorf("failed to unmarshal packages: %w", err)
}
}
if len(remotesJSON) > 0 {
if err := json.Unmarshal(remotesJSON, &record.ServerJSON.Remotes); err != nil {
if err := json.Unmarshal(remotesJSON, &record.Server.Remotes); err != nil {
return nil, fmt.Errorf("failed to unmarshal remotes: %w", err)
}
}
// Parse publisher extensions
if len(publisherExtensionsJSON) > 0 {
if err := json.Unmarshal(publisherExtensionsJSON, &record.PublisherExtensions); err != nil {
if err := json.Unmarshal(publisherExtensionsJSON, &record.XPublisher); err != nil {
return nil, fmt.Errorf("failed to unmarshal publisher extensions: %w", err)
}
} else {
record.PublisherExtensions = make(map[string]interface{})
record.XPublisher = make(map[string]interface{})
}
// Set registry metadata timestamps
record.RegistryMetadata.PublishedAt = publishedAt
record.RegistryMetadata.UpdatedAt = updatedAt
record.RegistryMetadata.ReleaseDate = releaseDate.Format(time.RFC3339)
record.XIOModelContextProtocolRegistry.PublishedAt = publishedAt
record.XIOModelContextProtocolRegistry.UpdatedAt = updatedAt
record.XIOModelContextProtocolRegistry.ReleaseDate = releaseDate.Format(time.RFC3339)
return &record, nil
}
// Publish adds a new server to the database with separated server.json and extensions
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) {
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, registryMetadata apiv1.RegistryExtensions) (*apiv1.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -292,7 +293,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
}
}()
// Prepare JSON data for server table
repositoryJSON, err := json.Marshal(serverDetail.Repository)
if err != nil {
@ -317,7 +317,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
// Use the same ID for both server and server_extensions records (1:1 relationship)
serverID := registryMetadata.ID
// Insert new server record
insertServerQuery := `
INSERT INTO servers (id, name, description, status, repository, version, packages, remotes)
@ -348,7 +347,7 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
registryMetadata.PublishedAt,
registryMetadata.UpdatedAt,
registryMetadata.IsLatest,
registryMetadata.PublishedAt, // release_date
registryMetadata.PublishedAt, // release_date
publisherExtensionsJSON,
)
if err != nil {
@ -361,10 +360,10 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
}
// Create and return the ServerRecord
record := &model.ServerRecord{
ServerJSON: serverDetail,
RegistryMetadata: registryMetadata,
PublisherExtensions: publisherExtensions,
record := &apiv1.ServerRecord{
Server: serverDetail,
XIOModelContextProtocolRegistry: registryMetadata,
XPublisher: publisherExtensions,
}
return record, nil
@ -392,11 +391,11 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error
// Import each server
for _, record := range seedData {
// Convert record to the format expected by Publish
serverDetail := record.ServerJSON
publisherExtensions := record.PublisherExtensions
serverDetail := record.Server
publisherExtensions := record.XPublisher
// Use the existing Publish logic but with specific ID from seed data
if err := db.publishWithTransaction(ctx, tx, serverDetail, publisherExtensions, &record.RegistryMetadata); err != nil {
if err := db.publishWithTransaction(ctx, tx, serverDetail, publisherExtensions, &record.XIOModelContextProtocolRegistry); err != nil {
return fmt.Errorf("failed to import server %s: %w", serverDetail.Name, err)
}
}
@ -410,7 +409,7 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error
}
// publishWithTransaction handles publishing within an existing transaction, optionally with predefined metadata
func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, existingMetadata *model.RegistryMetadata) error {
func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}, existingMetadata *apiv1.RegistryExtensions) error {
// Use the same ID for both server and server_extensions (1:1 relationship)
var id string
if existingMetadata != nil && existingMetadata.ID != "" {
@ -518,7 +517,7 @@ func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest
SET is_latest = $1, updated_at = $2
WHERE id = $3
`
result, err := db.conn.Exec(ctx, query, isLatest, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update latest flag: %w", err)
@ -532,7 +531,7 @@ func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest
}
// UpdateServer updates an existing server record with new server details
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
func (db *PostgreSQL) UpdateServer(ctx context.Context, id string, serverDetail model.ServerJSON, publisherExtensions map[string]interface{}) (*apiv1.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -641,4 +640,4 @@ func (db *PostgreSQL) Connection() *ConnectionInfo {
IsConnected: isConnected,
Raw: db.conn,
}
}
}