<!-- Provide a brief summary of your changes -->
This PR serves as a placeholder and brings together all of the changes
related to supporting vendor extensions passed through the API. The idea
is to have a single place to track and review the work as it comes
together (even if it’s still a bit of a work in progress).
**The changes in this PR cover:**
* Updates on the data model
* Updates on both databases (in-memory and postgresql), added a new
table to PSQL for tracking the extensions leaving the server table only
for the clean server.json properties
* Updates the seed.json file (thus the big line change)
* Adds a seed-migrate tool to help migrating an existing seed file to
the new format
* Updates the seed import mechanism to support this
* Updates the existing tests - unit, integration and validation tests
* Adds the publish handler to the OpenAPI spec (it was missing)
* Fixes linting errors
* Updates on the API side on (publish, list, get) to support the new
format of `ServerResponse` -
```
{
"server": {},
"x-io.modelcontextprotocol.registry": {}, /*not available for publish since this gets populated by the registry*/
"x-publisher": {}
}
```
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
Motivated by the discussions in:
https://github.com/modelcontextprotocol/registry/discussions/284
## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->
Locally and by running all of the tests
## Breaking Changes
<!-- Will users need to update their code or configurations? -->
Yes, it changes both the API and the format of the seed file
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
---
Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
Co-authored-by: adam jones <domdomegg+git@gmail.com>
355 lines
No EOL
13 KiB
Go
355 lines
No EOL
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/modelcontextprotocol/registry/internal/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
|
|
// createTestLegacySeed creates a small test file with legacy format data
|
|
func createTestLegacySeed(t *testing.T) string {
|
|
t.Helper()
|
|
testData := []OldServerFormat{
|
|
{
|
|
ID: "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0",
|
|
Name: "io.github.21st-dev/magic-mcp",
|
|
Description: "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic",
|
|
Repository: model.Repository{
|
|
URL: "https://github.com/21st-dev/magic-mcp",
|
|
Source: "github",
|
|
ID: "935450522",
|
|
},
|
|
VersionDetail: OldVersionDetail{
|
|
Version: "0.0.1-seed",
|
|
ReleaseDate: "2025-05-16T18:56:49Z",
|
|
IsLatest: true,
|
|
},
|
|
Packages: []model.Package{
|
|
{
|
|
RegistryName: "npm",
|
|
Name: "@21st-dev/magic",
|
|
Version: "0.0.46",
|
|
EnvironmentVariables: []model.KeyValueInput{
|
|
{
|
|
Name: "API_KEY",
|
|
InputWithVariables: model.InputWithVariables{
|
|
Input: model.Input{
|
|
Description: "${input:apiKey}",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "d3669201-252f-403c-944b-c3ec0845782b",
|
|
Name: "io.github.adfin-engineering/mcp-server-adfin",
|
|
Description: "A Model Context Protocol Server for connecting with Adfin APIs",
|
|
Repository: model.Repository{
|
|
URL: "https://github.com/Adfin-Engineering/mcp-server-adfin",
|
|
Source: "github",
|
|
ID: "951338147",
|
|
},
|
|
VersionDetail: OldVersionDetail{
|
|
Version: "0.0.1-seed",
|
|
ReleaseDate: "2025-05-16T18:56:52Z",
|
|
IsLatest: true,
|
|
},
|
|
Packages: []model.Package{
|
|
{
|
|
RegistryName: "pypi",
|
|
Name: "adfinmcp",
|
|
Version: "0.1.0",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tempFile, err := os.CreateTemp("", "test-legacy-seed-*.json")
|
|
require.NoError(t, err)
|
|
defer tempFile.Close()
|
|
|
|
jsonData, err := json.MarshalIndent(testData, "", " ")
|
|
require.NoError(t, err)
|
|
|
|
_, err = tempFile.Write(jsonData)
|
|
require.NoError(t, err)
|
|
|
|
return tempFile.Name()
|
|
}
|
|
|
|
func TestMigrationCLI(t *testing.T) {
|
|
// Create test legacy seed file
|
|
legacyFile := createTestLegacySeed(t)
|
|
defer os.Remove(legacyFile)
|
|
|
|
// Create output file path
|
|
outputFile := filepath.Join(t.TempDir(), "migrated-output.json")
|
|
|
|
// Run migration (simulate command line execution)
|
|
err := migrateSeed(legacyFile, outputFile)
|
|
require.NoError(t, err, "Migration should succeed")
|
|
|
|
// Read the output file
|
|
outputData, err := os.ReadFile(outputFile)
|
|
require.NoError(t, err, "Should be able to read output file")
|
|
|
|
// Parse as ServerResponse array (new format)
|
|
var migratedServers []model.ServerResponse
|
|
err = json.Unmarshal(outputData, &migratedServers)
|
|
require.NoError(t, err, "Output should be valid JSON in ServerResponse format")
|
|
|
|
// Verify we have the expected number of servers
|
|
assert.Len(t, migratedServers, 2, "Should have migrated 2 servers")
|
|
|
|
// Verify first server structure
|
|
server1 := migratedServers[0]
|
|
assert.Equal(t, "io.github.21st-dev/magic-mcp", server1.Server.Name)
|
|
assert.Equal(t, "It's like v0 but in your Cursor/WindSurf/Cline. 21st dev Magic MCP server for working with your frontend like Magic", server1.Server.Description)
|
|
assert.Equal(t, "https://github.com/21st-dev/magic-mcp", server1.Server.Repository.URL)
|
|
assert.Equal(t, "0.0.1-seed", server1.Server.VersionDetail.Version)
|
|
assert.Len(t, server1.Server.Packages, 1)
|
|
assert.Equal(t, "npm", server1.Server.Packages[0].RegistryName)
|
|
assert.Equal(t, "@21st-dev/magic", server1.Server.Packages[0].Name)
|
|
|
|
// Verify registry metadata extension
|
|
assert.NotNil(t, server1.XIOModelContextProtocolRegistry, "Should have registry metadata extension")
|
|
registryMeta1, ok := server1.XIOModelContextProtocolRegistry.(map[string]interface{})
|
|
require.True(t, ok, "Registry metadata should be a map")
|
|
assert.Equal(t, "4e9cf4cf-71f6-4aca-bae8-2d10a29ca2e0", registryMeta1["id"])
|
|
assert.Equal(t, true, registryMeta1["is_latest"])
|
|
assert.Equal(t, "2025-05-16T18:56:49Z", registryMeta1["release_date"])
|
|
|
|
// Verify second server structure
|
|
server2 := migratedServers[1]
|
|
assert.Equal(t, "io.github.adfin-engineering/mcp-server-adfin", server2.Server.Name)
|
|
assert.Equal(t, "A Model Context Protocol Server for connecting with Adfin APIs", server2.Server.Description)
|
|
assert.Equal(t, "pypi", server2.Server.Packages[0].RegistryName)
|
|
assert.Equal(t, "adfinmcp", server2.Server.Packages[0].Name)
|
|
|
|
// Verify registry metadata for second server
|
|
registryMeta2, ok := server2.XIOModelContextProtocolRegistry.(map[string]interface{})
|
|
require.True(t, ok, "Registry metadata should be a map")
|
|
assert.Equal(t, "d3669201-252f-403c-944b-c3ec0845782b", registryMeta2["id"])
|
|
assert.Equal(t, true, registryMeta2["is_latest"])
|
|
assert.Equal(t, "2025-05-16T18:56:52Z", registryMeta2["release_date"])
|
|
|
|
// Verify no x-publisher extensions (should be empty/nil for seed data)
|
|
assert.Nil(t, server1.XPublisher, "Seed data should not have x-publisher extensions")
|
|
assert.Nil(t, server2.XPublisher, "Seed data should not have x-publisher extensions")
|
|
}
|
|
|
|
// migrateSeed is a testable version of the main migration logic
|
|
func migrateSeed(inputFile, outputFile string) error {
|
|
// Read source data
|
|
data, err := os.ReadFile(inputFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse old format
|
|
var oldServers []OldServerFormat
|
|
if err := json.Unmarshal(data, &oldServers); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Convert to new format
|
|
var newServers []model.ServerResponse
|
|
for _, old := range oldServers {
|
|
converted := convertServer(old)
|
|
newServers = append(newServers, converted)
|
|
}
|
|
|
|
// Write migrated data
|
|
migratedData, err := json.MarshalIndent(newServers, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
if err := os.MkdirAll(filepath.Dir(outputFile), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(outputFile, migratedData, 0600)
|
|
}
|
|
|
|
func TestMigrationCLI_CommandLine(t *testing.T) {
|
|
// Create test legacy seed file
|
|
legacyFile := createTestLegacySeed(t)
|
|
defer os.Remove(legacyFile)
|
|
|
|
// Create output file path
|
|
outputFile := filepath.Join(t.TempDir(), "cli-migrated-output.json")
|
|
|
|
// Simulate command line arguments
|
|
oldArgs := os.Args
|
|
defer func() { os.Args = oldArgs }()
|
|
os.Args = []string{"migrate-seed", legacyFile, outputFile}
|
|
|
|
// Capture any panics or exits from main()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Errorf("main() panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
// Test the main function (Note: this would normally call os.Exit, but in tests it should return)
|
|
main()
|
|
|
|
// Verify the output file was created and has correct content
|
|
require.FileExists(t, outputFile, "Output file should be created")
|
|
|
|
outputData, err := os.ReadFile(outputFile)
|
|
require.NoError(t, err, "Should be able to read output file")
|
|
|
|
var migratedServers []model.ServerResponse
|
|
err = json.Unmarshal(outputData, &migratedServers)
|
|
require.NoError(t, err, "Output should be valid JSON in ServerResponse format")
|
|
|
|
// Basic verification
|
|
assert.Len(t, migratedServers, 2, "Should have migrated 2 servers")
|
|
assert.Equal(t, "io.github.21st-dev/magic-mcp", migratedServers[0].Server.Name)
|
|
assert.Equal(t, "io.github.adfin-engineering/mcp-server-adfin", migratedServers[1].Server.Name)
|
|
}
|
|
|
|
func TestMigrationCLI_ErrorHandling(t *testing.T) {
|
|
t.Run("Invalid JSON input", func(t *testing.T) {
|
|
// Create invalid JSON file
|
|
invalidFile, err := os.CreateTemp("", "invalid-*.json")
|
|
require.NoError(t, err)
|
|
defer os.Remove(invalidFile.Name())
|
|
|
|
_, err = invalidFile.WriteString(`{"invalid": json}`)
|
|
require.NoError(t, err)
|
|
invalidFile.Close()
|
|
|
|
outputFile := filepath.Join(t.TempDir(), "output.json")
|
|
err = migrateSeed(invalidFile.Name(), outputFile)
|
|
assert.Error(t, err, "Should error on invalid JSON")
|
|
})
|
|
|
|
t.Run("Missing input file", func(t *testing.T) {
|
|
outputFile := filepath.Join(t.TempDir(), "output.json")
|
|
err := migrateSeed("/nonexistent/file.json", outputFile)
|
|
assert.Error(t, err, "Should error on missing input file")
|
|
})
|
|
|
|
t.Run("Empty input file", func(t *testing.T) {
|
|
// Create empty JSON array
|
|
emptyFile, err := os.CreateTemp("", "empty-*.json")
|
|
require.NoError(t, err)
|
|
defer os.Remove(emptyFile.Name())
|
|
|
|
_, err = emptyFile.WriteString("[]")
|
|
require.NoError(t, err)
|
|
emptyFile.Close()
|
|
|
|
outputFile := filepath.Join(t.TempDir(), "output.json")
|
|
err = migrateSeed(emptyFile.Name(), outputFile)
|
|
assert.NoError(t, err, "Empty array should be valid")
|
|
|
|
// Verify empty output
|
|
outputData, err := os.ReadFile(outputFile)
|
|
require.NoError(t, err)
|
|
|
|
var result []model.ServerResponse
|
|
err = json.Unmarshal(outputData, &result)
|
|
require.NoError(t, err, "Should be valid JSON")
|
|
assert.Len(t, result, 0, "Should have no servers")
|
|
})
|
|
}
|
|
|
|
func TestMigrationCLI_GoldenFile(t *testing.T) {
|
|
// Test migration with predefined input and expected output files
|
|
inputFile := "testdata/legacy-input.json"
|
|
expectedFile := "testdata/expected-output.json"
|
|
|
|
// Ensure test files exist
|
|
require.FileExists(t, inputFile, "Input test file should exist")
|
|
require.FileExists(t, expectedFile, "Expected output test file should exist")
|
|
|
|
// Run migration on test input
|
|
outputFile := filepath.Join(t.TempDir(), "golden-output.json")
|
|
err := migrateSeed(inputFile, outputFile)
|
|
require.NoError(t, err, "Migration should succeed")
|
|
|
|
// Read expected and actual output
|
|
expectedData, err := os.ReadFile(expectedFile)
|
|
require.NoError(t, err, "Should be able to read expected output")
|
|
|
|
actualData, err := os.ReadFile(outputFile)
|
|
require.NoError(t, err, "Should be able to read actual output")
|
|
|
|
// Parse both as JSON to normalize formatting and compare semantically
|
|
var expectedJSON, actualJSON []model.ServerResponse
|
|
|
|
err = json.Unmarshal(expectedData, &expectedJSON)
|
|
require.NoError(t, err, "Expected output should be valid JSON")
|
|
|
|
err = json.Unmarshal(actualData, &actualJSON)
|
|
require.NoError(t, err, "Actual output should be valid JSON")
|
|
|
|
// Compare the parsed structures
|
|
assert.Equal(t, expectedJSON, actualJSON, "Migration output should match expected golden file")
|
|
|
|
// Also compare server count
|
|
assert.Len(t, actualJSON, len(expectedJSON), "Should have same number of servers")
|
|
|
|
// Verify specific server names to ensure ordering is preserved
|
|
if len(expectedJSON) >= 2 && len(actualJSON) >= 2 {
|
|
assert.Equal(t, expectedJSON[0].Server.Name, actualJSON[0].Server.Name, "First server name should match")
|
|
assert.Equal(t, expectedJSON[1].Server.Name, actualJSON[1].Server.Name, "Second server name should match")
|
|
}
|
|
}
|
|
|
|
func TestGoldenFiles_Validation(t *testing.T) {
|
|
// Validate that the golden files themselves are valid
|
|
t.Run("Input file is valid legacy format", func(t *testing.T) {
|
|
inputData, err := os.ReadFile("testdata/legacy-input.json")
|
|
require.NoError(t, err, "Should be able to read input file")
|
|
|
|
var legacyServers []OldServerFormat
|
|
err = json.Unmarshal(inputData, &legacyServers)
|
|
require.NoError(t, err, "Input file should be valid legacy format JSON")
|
|
|
|
assert.Len(t, legacyServers, 2, "Input should contain exactly 2 servers")
|
|
|
|
// Verify it contains expected server names
|
|
names := []string{legacyServers[0].Name, legacyServers[1].Name}
|
|
assert.Contains(t, names, "io.github.21st-dev/magic-mcp")
|
|
assert.Contains(t, names, "io.github.adfin-engineering/mcp-server-adfin")
|
|
})
|
|
|
|
t.Run("Expected output is valid extension wrapper format", func(t *testing.T) {
|
|
expectedData, err := os.ReadFile("testdata/expected-output.json")
|
|
require.NoError(t, err, "Should be able to read expected output file")
|
|
|
|
var expectedServers []model.ServerResponse
|
|
err = json.Unmarshal(expectedData, &expectedServers)
|
|
require.NoError(t, err, "Expected output should be valid ServerResponse format JSON")
|
|
|
|
assert.Len(t, expectedServers, 2, "Expected output should contain exactly 2 servers")
|
|
|
|
// Verify structure of first server
|
|
server1 := expectedServers[0]
|
|
assert.NotEmpty(t, server1.Server.Name, "Server should have a name")
|
|
assert.NotEmpty(t, server1.Server.Description, "Server should have a description")
|
|
assert.NotNil(t, server1.XIOModelContextProtocolRegistry, "Should have registry metadata")
|
|
assert.Nil(t, server1.XPublisher, "Seed data should not have publisher extensions")
|
|
|
|
// Verify registry metadata contains required fields
|
|
registryMeta, ok := server1.XIOModelContextProtocolRegistry.(map[string]interface{})
|
|
require.True(t, ok, "Registry metadata should be a map")
|
|
assert.Contains(t, registryMeta, "id", "Registry metadata should have id")
|
|
assert.Contains(t, registryMeta, "is_latest", "Registry metadata should have is_latest")
|
|
assert.Contains(t, registryMeta, "release_date", "Registry metadata should have release_date")
|
|
})
|
|
} |