diff --git a/Taskfile.yml b/Taskfile.yml index 4a2d410..e10133e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -260,13 +260,6 @@ tasks: cmds: - ./{{.BUILD_DIR}}/registry-builder version - sync-schema: - desc: Sync schema reference with Go dependency version - cmds: - - echo "šŸ”„ Syncing schema version with Go dependency..." - - ./scripts/sync-schema-version.sh - - echo "āœ… Schema sync complete. Run 'task validate' to verify." - watch: desc: Watch for changes and rebuild (requires entr) cmds: diff --git a/go.mod b/go.mod index 3001229..aecc3ff 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/stacklok/toolhive-registry -go 1.24.5 +go 1.24.6 require ( github.com/google/go-cmp v0.7.0 - github.com/google/uuid v1.6.0 - github.com/modelcontextprotocol/registry v1.0.0 + github.com/modelcontextprotocol/registry v1.3.5 github.com/spf13/cobra v1.10.1 github.com/stacklok/toolhive v0.3.7 github.com/stretchr/testify v1.11.1 @@ -60,7 +59,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.3 // indirect - github.com/go-jose/go-jose/v4 v4.1.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect @@ -93,6 +92,7 @@ require ( github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect @@ -165,7 +165,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.42.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.28.0 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.31.0 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index 779e208..abf5b96 100644 --- a/go.sum +++ b/go.sum @@ -749,8 +749,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= -github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= +github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= @@ -828,8 +828,8 @@ github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmn github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= -github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -1070,8 +1070,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= -github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= @@ -1131,8 +1131,8 @@ github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7z github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/modelcontextprotocol/registry v1.0.0 h1:RxTSh2tC05Mlc3B2AzY/Oos1Fthuwe+OrK6a/17OCRE= -github.com/modelcontextprotocol/registry v1.0.0/go.mod h1:D6U1q6wYKYMA58q2gZz4eFsghr+fTkZQY8/ZFwTOT1Q= +github.com/modelcontextprotocol/registry v1.3.5 h1:M1ZTKPkxVICKlFBYio01/CkHbpjMcaGjLFF5M5QgZxc= +github.com/modelcontextprotocol/registry v1.3.5/go.mod h1:68KOBW2R5FX53BTrid2OFvPoCKEEYZk6z8LUa2ahLM0= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -1434,8 +1434,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/registry/converters/converters_fixture_test.go b/pkg/registry/converters/converters_fixture_test.go new file mode 100644 index 0000000..2c15875 --- /dev/null +++ b/pkg/registry/converters/converters_fixture_test.go @@ -0,0 +1,258 @@ +package converters + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConverters_Fixtures validates converter functions using JSON fixture files +// This provides a clear, maintainable way to test conversions with real-world data +func TestConverters_Fixtures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixtureDir string + inputFile string + expectedFile string + serverName string + convertFunc string // "ImageToServer", "ServerToImage", "RemoteToServer", "ServerToRemote" + validateFunc func(t *testing.T, input, output []byte) + }{ + { + name: "ImageMetadata to ServerJSON - GitHub", + fixtureDir: "testdata/image_to_server", + inputFile: "input_github.json", + expectedFile: "expected_github.json", + serverName: "github", + convertFunc: "ImageToServer", + validateFunc: validateImageToServerConversion, + }, + { + name: "ServerJSON to ImageMetadata - GitHub", + fixtureDir: "testdata/server_to_image", + inputFile: "input_github.json", + expectedFile: "expected_github.json", + serverName: "", + convertFunc: "ServerToImage", + validateFunc: validateServerToImageConversion, + }, + { + name: "RemoteServerMetadata to ServerJSON - Example", + fixtureDir: "testdata/remote_to_server", + inputFile: "input_example.json", + expectedFile: "expected_example.json", + serverName: "example-remote", + convertFunc: "RemoteToServer", + validateFunc: validateRemoteToServerConversion, + }, + { + name: "ServerJSON to RemoteServerMetadata - Example", + fixtureDir: "testdata/server_to_remote", + inputFile: "input_example.json", + expectedFile: "expected_example.json", + serverName: "", + convertFunc: "ServerToRemote", + validateFunc: validateServerToRemoteConversion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Read input fixture + inputPath := filepath.Join(tt.fixtureDir, tt.inputFile) + inputData, err := os.ReadFile(inputPath) + require.NoError(t, err, "Failed to read input fixture: %s", inputPath) + + // Read expected output fixture + expectedPath := filepath.Join(tt.fixtureDir, tt.expectedFile) + expectedData, err := os.ReadFile(expectedPath) + require.NoError(t, err, "Failed to read expected fixture: %s", expectedPath) + + // Perform conversion based on type + var actualData []byte + switch tt.convertFunc { + case "ImageToServer": + actualData = convertImageToServer(t, inputData, tt.serverName) + case "ServerToImage": + actualData = convertServerToImage(t, inputData) + case "RemoteToServer": + actualData = convertRemoteToServer(t, inputData, tt.serverName) + case "ServerToRemote": + actualData = convertServerToRemote(t, inputData) + default: + t.Fatalf("Unknown conversion function: %s", tt.convertFunc) + } + + // Compare output with expected + var expected, actual interface{} + require.NoError(t, json.Unmarshal(expectedData, &expected), "Failed to parse expected JSON") + require.NoError(t, json.Unmarshal(actualData, &actual), "Failed to parse actual JSON") + + // Deep equal comparison + assert.Equal(t, expected, actual, "Conversion output doesn't match expected fixture") + + // Run additional validation if provided + if tt.validateFunc != nil { + tt.validateFunc(t, inputData, actualData) + } + }) + } +} + +// Helper functions for conversions + +func convertImageToServer(t *testing.T, inputData []byte, serverName string) []byte { + t.Helper() + var imageMetadata registry.ImageMetadata + require.NoError(t, json.Unmarshal(inputData, &imageMetadata)) + + serverJSON, err := ImageMetadataToServerJSON(serverName, &imageMetadata) + require.NoError(t, err) + + output, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + return output +} + +func convertServerToImage(t *testing.T, inputData []byte) []byte { + t.Helper() + var serverJSON upstream.ServerJSON + require.NoError(t, json.Unmarshal(inputData, &serverJSON)) + + imageMetadata, err := ServerJSONToImageMetadata(&serverJSON) + require.NoError(t, err) + + output, err := json.MarshalIndent(imageMetadata, "", " ") + require.NoError(t, err) + return output +} + +func convertRemoteToServer(t *testing.T, inputData []byte, serverName string) []byte { + t.Helper() + var remoteMetadata registry.RemoteServerMetadata + require.NoError(t, json.Unmarshal(inputData, &remoteMetadata)) + + serverJSON, err := RemoteServerMetadataToServerJSON(serverName, &remoteMetadata) + require.NoError(t, err) + + output, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + return output +} + +func convertServerToRemote(t *testing.T, inputData []byte) []byte { + t.Helper() + var serverJSON upstream.ServerJSON + require.NoError(t, json.Unmarshal(inputData, &serverJSON)) + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(&serverJSON) + require.NoError(t, err) + + output, err := json.MarshalIndent(remoteMetadata, "", " ") + require.NoError(t, err) + return output +} + +// Validation functions - additional checks beyond JSON equality + +func validateImageToServerConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input registry.ImageMetadata + var output upstream.ServerJSON + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + assert.Len(t, output.Packages, 1, "Should have exactly one package") + assert.Equal(t, input.Image, output.Packages[0].Identifier, "Image identifier should match") + assert.Equal(t, input.Transport, output.Packages[0].Transport.Type, "Transport type should match") + + // Verify environment variables count + assert.Len(t, output.Packages[0].EnvironmentVariables, len(input.EnvVars), + "Environment variables count should match") + + // Verify publisher extensions exist + require.NotNil(t, output.Meta, "Meta should not be nil") + require.NotNil(t, output.Meta.PublisherProvided, "PublisherProvided should not be nil") + + stacklokData, ok := output.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok, "Should have io.github.stacklok namespace") + + extensions, ok := stacklokData[input.Image].(map[string]interface{}) + require.True(t, ok, "Should have image-specific extensions") + + // Verify key extension fields + assert.Equal(t, input.Status, extensions["status"], "Status should be in extensions") + assert.Equal(t, input.Tier, extensions["tier"], "Tier should be in extensions") + assert.NotNil(t, extensions["tools"], "Tools should be in extensions") + assert.NotNil(t, extensions["tags"], "Tags should be in extensions") +} + +func validateServerToImageConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input upstream.ServerJSON + var output registry.ImageMetadata + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + require.Len(t, input.Packages, 1, "Input should have exactly one package") + assert.Equal(t, input.Packages[0].Identifier, output.Image, "Image identifier should match") + assert.Equal(t, input.Packages[0].Transport.Type, output.Transport, "Transport type should match") + + // Verify environment variables were extracted + assert.Len(t, output.EnvVars, len(input.Packages[0].EnvironmentVariables), + "Environment variables count should match") +} + +func validateRemoteToServerConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input registry.RemoteServerMetadata + var output upstream.ServerJSON + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + require.Len(t, output.Remotes, 1, "Should have exactly one remote") + assert.Equal(t, input.URL, output.Remotes[0].URL, "Remote URL should match") + assert.Equal(t, input.Transport, output.Remotes[0].Type, "Transport type should match") + + // Verify headers count + assert.Len(t, output.Remotes[0].Headers, len(input.Headers), + "Headers count should match") +} + +func validateServerToRemoteConversion(t *testing.T, inputData, outputData []byte) { + t.Helper() + var input upstream.ServerJSON + var output registry.RemoteServerMetadata + + require.NoError(t, json.Unmarshal(inputData, &input)) + require.NoError(t, json.Unmarshal(outputData, &output)) + + // Verify core mappings + assert.Equal(t, input.Description, output.Description, "Description should match") + require.Len(t, input.Remotes, 1, "Input should have exactly one remote") + assert.Equal(t, input.Remotes[0].URL, output.URL, "Remote URL should match") + assert.Equal(t, input.Remotes[0].Type, output.Transport, "Transport type should match") + + // Verify headers were extracted + assert.Len(t, output.Headers, len(input.Remotes[0].Headers), + "Headers count should match") +} diff --git a/pkg/registry/converters/converters_test.go b/pkg/registry/converters/converters_test.go new file mode 100644 index 0000000..e4ec73b --- /dev/null +++ b/pkg/registry/converters/converters_test.go @@ -0,0 +1,1265 @@ +package converters + +import ( + "encoding/json" + "testing" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test Helpers + +// createTestServerJSON creates a valid ServerJSON for testing with OCI package +func createTestServerJSON() *upstream.ServerJSON { + return &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "io.github.stacklok/test-server", + Description: "Test MCP server", + Version: "1.0.0", + Repository: model.Repository{ + URL: "https://github.com/test/repo", + Source: "github", + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/test/server:latest", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "ghcr.io/test/server:latest": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{"tool1", "tool2"}, + "tags": []interface{}{"test", "example"}, + "metadata": map[string]interface{}{ + "stars": float64(100), + "pulls": float64(1000), + "last_updated": "2025-01-01", + }, + }, + }, + }, + }, + } +} + +// createTestImageMetadata creates a valid ImageMetadata for testing +func createTestImageMetadata() *registry.ImageMetadata { + return ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Test MCP server", + Transport: model.TransportTypeStdio, + RepositoryURL: "https://github.com/test/repo", + Status: "active", + Tier: "Official", + Tools: []string{"tool1", "tool2"}, + Tags: []string{"test", "example"}, + Metadata: ®istry.Metadata{ + Stars: 100, + Pulls: 1000, + LastUpdated: "2025-01-01", + }, + }, + Image: "ghcr.io/test/server:latest", + } +} + +// createTestRemoteServerMetadata creates a valid RemoteServerMetadata for testing +func createTestRemoteServerMetadata() *registry.RemoteServerMetadata { + return ®istry.RemoteServerMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Test remote server", + Transport: "sse", + RepositoryURL: "https://github.com/test/remote", + Status: "active", + Tier: "Official", + Tools: []string{"tool1"}, + Tags: []string{"remote"}, + }, + URL: "https://api.example.com/mcp", + } +} + +// Test Suite 1: ServerJSONToImageMetadata + +func TestServerJSONToImageMetadata_Success(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + + assert.Equal(t, "ghcr.io/test/server:latest", imageMetadata.Image) + assert.Equal(t, "Test MCP server", imageMetadata.Description) + assert.Equal(t, model.TransportTypeStdio, imageMetadata.Transport) + assert.Equal(t, "https://github.com/test/repo", imageMetadata.RepositoryURL) + assert.Equal(t, "active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + assert.Equal(t, []string{"tool1", "tool2"}, imageMetadata.Tools) + assert.Equal(t, []string{"test", "example"}, imageMetadata.Tags) + assert.NotNil(t, imageMetadata.Metadata) + assert.Equal(t, 100, imageMetadata.Metadata.Stars) + assert.Equal(t, 1000, imageMetadata.Metadata.Pulls) + assert.Equal(t, "2025-01-01", imageMetadata.Metadata.LastUpdated) +} + +func TestServerJSONToImageMetadata_NilInput(t *testing.T) { + t.Parallel() + + imageMetadata, err := ServerJSONToImageMetadata(nil) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "serverJSON cannot be nil") +} + +func TestServerJSONToImageMetadata_NoPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{}, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has no packages") +} + +func TestServerJSONToImageMetadata_NoOCIPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{ + { + RegistryType: "npm", + Identifier: "test-package", + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has no OCI packages") +} + +func TestServerJSONToImageMetadata_MultipleOCIPackages(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "image1:latest", + }, + { + RegistryType: model.RegistryTypeOCI, + Identifier: "image2:latest", + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, imageMetadata) + assert.Contains(t, err.Error(), "has 2 OCI packages") +} + +func TestServerJSONToImageMetadata_WithEnvVars(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].EnvironmentVariables = []model.KeyValueInput{ + { + Name: "API_KEY", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "API Key", + IsRequired: true, + IsSecret: true, + Default: "default-key", + }, + }, + }, + { + Name: "DEBUG", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Debug mode", + IsRequired: false, + IsSecret: false, + Default: "false", + }, + }, + }, + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + require.Len(t, imageMetadata.EnvVars, 2) + + assert.Equal(t, "API_KEY", imageMetadata.EnvVars[0].Name) + assert.Equal(t, "API Key", imageMetadata.EnvVars[0].Description) + assert.True(t, imageMetadata.EnvVars[0].Required) + assert.True(t, imageMetadata.EnvVars[0].Secret) + assert.Equal(t, "default-key", imageMetadata.EnvVars[0].Default) + + assert.Equal(t, "DEBUG", imageMetadata.EnvVars[1].Name) + assert.Equal(t, "Debug mode", imageMetadata.EnvVars[1].Description) + assert.False(t, imageMetadata.EnvVars[1].Required) + assert.False(t, imageMetadata.EnvVars[1].Secret) + assert.Equal(t, "false", imageMetadata.EnvVars[1].Default) +} + +func TestServerJSONToImageMetadata_WithTargetPort(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].Transport = model.Transport{ + Type: model.TransportTypeStreamableHTTP, + URL: "http://localhost:9090", + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, 9090, imageMetadata.TargetPort) +} + +func TestServerJSONToImageMetadata_InvalidPortURL(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Packages[0].Transport = model.Transport{ + Type: model.TransportTypeStreamableHTTP, + URL: "not-a-valid-url", + } + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, 0, imageMetadata.TargetPort) // Should default to 0 on parse failure +} + +func TestServerJSONToImageMetadata_MissingPublisherExtensions(t *testing.T) { + t.Parallel() + + serverJSON := createTestServerJSON() + serverJSON.Meta = nil + + imageMetadata, err := ServerJSONToImageMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, imageMetadata) + assert.Equal(t, "", imageMetadata.Status) + assert.Equal(t, "", imageMetadata.Tier) + assert.Nil(t, imageMetadata.Tools) + assert.Nil(t, imageMetadata.Tags) + assert.Nil(t, imageMetadata.Metadata) +} + +// Test Suite 2: ImageMetadataToServerJSON + +func TestImageMetadataToServerJSON_Success(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("test-server", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/test-server", serverJSON.Name) + assert.Equal(t, "Test MCP server", serverJSON.Description) + assert.Equal(t, "1.0.0", serverJSON.Version) + assert.Equal(t, "https://github.com/test/repo", serverJSON.Repository.URL) + assert.Len(t, serverJSON.Packages, 1) + assert.Equal(t, model.RegistryTypeOCI, serverJSON.Packages[0].RegistryType) + assert.Equal(t, "ghcr.io/test/server:latest", serverJSON.Packages[0].Identifier) + assert.NotNil(t, serverJSON.Meta) + assert.NotNil(t, serverJSON.Meta.PublisherProvided) +} + +func TestImageMetadataToServerJSON_NilInput(t *testing.T) { + t.Parallel() + + serverJSON, err := ImageMetadataToServerJSON("test", nil) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "imageMetadata cannot be nil") +} + +func TestImageMetadataToServerJSON_EmptyName(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("", imageMetadata) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "name cannot be empty") +} + +func TestImageMetadataToServerJSON_WithEnvVars(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.EnvVars = []*registry.EnvVar{ + { + Name: "API_KEY", + Description: "API Key", + Required: true, + Secret: true, + Default: "default", + }, + } + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + require.Len(t, serverJSON.Packages[0].EnvironmentVariables, 1) + + envVar := serverJSON.Packages[0].EnvironmentVariables[0] + assert.Equal(t, "API_KEY", envVar.Name) + assert.Equal(t, "API Key", envVar.Description) + assert.True(t, envVar.IsRequired) + assert.True(t, envVar.IsSecret) + assert.Equal(t, "default", envVar.Default) +} + +func TestImageMetadataToServerJSON_WithTargetPort(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStreamableHTTP + imageMetadata.TargetPort = 9090 + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStreamableHTTP, serverJSON.Packages[0].Transport.Type) + assert.Equal(t, "http://localhost:9090", serverJSON.Packages[0].Transport.URL) +} + +func TestImageMetadataToServerJSON_HTTPTransportNoPort(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStreamableHTTP + imageMetadata.TargetPort = 0 // No port specified + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStreamableHTTP, serverJSON.Packages[0].Transport.Type) + assert.Equal(t, "http://localhost", serverJSON.Packages[0].Transport.URL) // No port in URL +} + +func TestImageMetadataToServerJSON_StdioTransport(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = model.TransportTypeStdio + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStdio, serverJSON.Packages[0].Transport.Type) + assert.Empty(t, serverJSON.Packages[0].Transport.URL) +} + +func TestImageMetadataToServerJSON_EmptyTransportDefaultsToStdio(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + imageMetadata.Transport = "" + + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Packages, 1) + + assert.Equal(t, model.TransportTypeStdio, serverJSON.Packages[0].Transport.Type) +} + +func TestImageMetadataToServerJSON_WithPublisherExtensions(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("test", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + imageData, ok := stacklokData["ghcr.io/test/server:latest"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", imageData["status"]) + assert.Equal(t, "Official", imageData["tier"]) +} + +func TestImageMetadataToServerJSON_ReverseDNSName(t *testing.T) { + t.Parallel() + + imageMetadata := createTestImageMetadata() + serverJSON, err := ImageMetadataToServerJSON("fetch", imageMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + assert.Equal(t, "io.github.stacklok/fetch", serverJSON.Name) +} + +// Test Suite 3: ServerJSONToRemoteServerMetadata + +func TestServerJSONToRemoteServerMetadata_Success(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "io.github.stacklok/test-remote", + Description: "Test remote server", + Repository: model.Repository{ + URL: "https://github.com/test/remote", + }, + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com/mcp", + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "https://api.example.com/mcp": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{"tool1"}, + }, + }, + }, + }, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + + assert.Equal(t, "https://api.example.com/mcp", remoteMetadata.URL) + assert.Equal(t, "Test remote server", remoteMetadata.Description) + assert.Equal(t, "sse", remoteMetadata.Transport) + assert.Equal(t, "https://github.com/test/remote", remoteMetadata.RepositoryURL) + assert.Equal(t, "active", remoteMetadata.Status) + assert.Equal(t, "Official", remoteMetadata.Tier) + assert.Equal(t, []string{"tool1"}, remoteMetadata.Tools) +} + +func TestServerJSONToRemoteServerMetadata_NilInput(t *testing.T) { + t.Parallel() + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(nil) + + assert.Error(t, err) + assert.Nil(t, remoteMetadata) + assert.Contains(t, err.Error(), "serverJSON cannot be nil") +} + +func TestServerJSONToRemoteServerMetadata_NoRemotes(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Remotes: []model.Transport{}, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + assert.Error(t, err) + assert.Nil(t, remoteMetadata) + assert.Contains(t, err.Error(), "has no remotes") +} + +func TestServerJSONToRemoteServerMetadata_WithHeaders(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Description: "Test", + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com", + Headers: []model.KeyValueInput{ + { + Name: "Authorization", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Auth token", + IsRequired: true, + IsSecret: true, + }, + }, + }, + }, + }, + }, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + require.Len(t, remoteMetadata.Headers, 1) + + assert.Equal(t, "Authorization", remoteMetadata.Headers[0].Name) + assert.Equal(t, "Auth token", remoteMetadata.Headers[0].Description) + assert.True(t, remoteMetadata.Headers[0].Required) + assert.True(t, remoteMetadata.Headers[0].Secret) +} + +func TestServerJSONToRemoteServerMetadata_MissingPublisherExtensions(t *testing.T) { + t.Parallel() + + serverJSON := &upstream.ServerJSON{ + Name: "test", + Description: "Test", + Remotes: []model.Transport{ + { + Type: "sse", + URL: "https://api.example.com", + }, + }, + Meta: nil, + } + + remoteMetadata, err := ServerJSONToRemoteServerMetadata(serverJSON) + + require.NoError(t, err) + require.NotNil(t, remoteMetadata) + assert.Equal(t, "", remoteMetadata.Status) + assert.Equal(t, "", remoteMetadata.Tier) +} + +// Test Suite 4: RemoteServerMetadataToServerJSON + +func TestRemoteServerMetadataToServerJSON_Success(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("test-remote", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/test-remote", serverJSON.Name) + assert.Equal(t, "Test remote server", serverJSON.Description) + assert.Equal(t, "https://github.com/test/remote", serverJSON.Repository.URL) + assert.Len(t, serverJSON.Remotes, 1) + assert.Equal(t, "sse", serverJSON.Remotes[0].Type) + assert.Equal(t, "https://api.example.com/mcp", serverJSON.Remotes[0].URL) +} + +func TestRemoteServerMetadataToServerJSON_NilInput(t *testing.T) { + t.Parallel() + + serverJSON, err := RemoteServerMetadataToServerJSON("test", nil) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "remoteMetadata cannot be nil") +} + +func TestRemoteServerMetadataToServerJSON_EmptyName(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("", remoteMetadata) + + assert.Error(t, err) + assert.Nil(t, serverJSON) + assert.Contains(t, err.Error(), "name cannot be empty") +} + +func TestRemoteServerMetadataToServerJSON_WithHeaders(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + remoteMetadata.Headers = []*registry.Header{ + { + Name: "Authorization", + Description: "Auth header", + Required: true, + Secret: true, + }, + } + + serverJSON, err := RemoteServerMetadataToServerJSON("test", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.Len(t, serverJSON.Remotes, 1) + require.Len(t, serverJSON.Remotes[0].Headers, 1) + + header := serverJSON.Remotes[0].Headers[0] + assert.Equal(t, "Authorization", header.Name) + assert.Equal(t, "Auth header", header.Description) + assert.True(t, header.IsRequired) + assert.True(t, header.IsSecret) +} + +func TestRemoteServerMetadataToServerJSON_WithPublisherExtensions(t *testing.T) { + t.Parallel() + + remoteMetadata := createTestRemoteServerMetadata() + serverJSON, err := RemoteServerMetadataToServerJSON("test", remoteMetadata) + + require.NoError(t, err) + require.NotNil(t, serverJSON) + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + + remoteData, ok := stacklokData["https://api.example.com/mcp"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, "active", remoteData["status"]) + assert.Equal(t, "Official", remoteData["tier"]) +} + +// Test Suite 5: Utility Functions + +func TestExtractServerName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "reverse DNS format", + input: "io.github.stacklok/fetch", + expected: "fetch", + }, + { + name: "no slash", + input: "fetch", + expected: "fetch", + }, + { + name: "returns original if multiple slashes", + input: "io.github.stacklok/mcp/server", + expected: "io.github.stacklok/mcp/server", // Function only splits on first slash, returns original if not exactly 2 parts + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := ExtractServerName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildReverseDNSName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple name", + input: "fetch", + expected: "io.github.stacklok/fetch", + }, + { + name: "already formatted", + input: "io.github.stacklok/fetch", + expected: "io.github.stacklok/fetch", + }, + { + name: "other namespace", + input: "com.example/server", + expected: "com.example/server", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := BuildReverseDNSName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Test Suite 6: Round-trip Conversion Tests + +func TestRoundTrip_ImageMetadata(t *testing.T) { + t.Parallel() + + // Start with ImageMetadata + original := createTestImageMetadata() + + // Convert to ServerJSON + serverJSON, err := ImageMetadataToServerJSON("test-server", original) + require.NoError(t, err) + + // Convert back to ImageMetadata + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + + // Verify data preserved + assert.Equal(t, original.Image, result.Image) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) + + if original.Metadata != nil { + require.NotNil(t, result.Metadata) + assert.Equal(t, original.Metadata.Stars, result.Metadata.Stars) + assert.Equal(t, original.Metadata.Pulls, result.Metadata.Pulls) + assert.Equal(t, original.Metadata.LastUpdated, result.Metadata.LastUpdated) + } +} + +func TestRoundTrip_RemoteServerMetadata(t *testing.T) { + t.Parallel() + + // Start with RemoteServerMetadata + original := createTestRemoteServerMetadata() + + // Convert to ServerJSON + serverJSON, err := RemoteServerMetadataToServerJSON("test-remote", original) + require.NoError(t, err) + + // Convert back to RemoteServerMetadata + result, err := ServerJSONToRemoteServerMetadata(serverJSON) + require.NoError(t, err) + + // Verify data preserved + assert.Equal(t, original.URL, result.URL) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) +} + +func TestRoundTrip_ImageMetadataWithAllFields(t *testing.T) { + t.Parallel() + + // Create ImageMetadata with maximum field population + original := ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Full featured server", + Transport: model.TransportTypeStreamableHTTP, + RepositoryURL: "https://github.com/test/full", + Status: "active", + Tier: "Official", + Tools: []string{"tool1", "tool2", "tool3"}, + Tags: []string{"tag1", "tag2"}, + Metadata: ®istry.Metadata{ + Stars: 500, + Pulls: 10000, + LastUpdated: "2025-10-23", + }, + }, + Image: "ghcr.io/test/full:v1.0.0", + TargetPort: 8080, + EnvVars: []*registry.EnvVar{ + { + Name: "API_KEY", + Description: "API Key for authentication", + Required: true, + Secret: true, + Default: "", + }, + { + Name: "LOG_LEVEL", + Description: "Logging level", + Required: false, + Secret: false, + Default: "info", + }, + }, + } + + // Round trip + serverJSON, err := ImageMetadataToServerJSON("full-server", original) + require.NoError(t, err) + + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + + // Verify all fields preserved + assert.Equal(t, original.Image, result.Image) + assert.Equal(t, original.Description, result.Description) + assert.Equal(t, original.Transport, result.Transport) + assert.Equal(t, original.RepositoryURL, result.RepositoryURL) + assert.Equal(t, original.Status, result.Status) + assert.Equal(t, original.Tier, result.Tier) + assert.Equal(t, original.Tools, result.Tools) + assert.Equal(t, original.Tags, result.Tags) + assert.Equal(t, original.TargetPort, result.TargetPort) + + require.Len(t, result.EnvVars, len(original.EnvVars)) + for i := range original.EnvVars { + assert.Equal(t, original.EnvVars[i].Name, result.EnvVars[i].Name) + assert.Equal(t, original.EnvVars[i].Description, result.EnvVars[i].Description) + assert.Equal(t, original.EnvVars[i].Required, result.EnvVars[i].Required) + assert.Equal(t, original.EnvVars[i].Secret, result.EnvVars[i].Secret) + assert.Equal(t, original.EnvVars[i].Default, result.EnvVars[i].Default) + } + + require.NotNil(t, result.Metadata) + assert.Equal(t, original.Metadata.Stars, result.Metadata.Stars) + assert.Equal(t, original.Metadata.Pulls, result.Metadata.Pulls) + assert.Equal(t, original.Metadata.LastUpdated, result.Metadata.LastUpdated) +} + +// TestRealWorld_GitHubServer tests conversion using the actual GitHub MCP server data as a template +// This test verifies that our converters can handle real-world production data correctly +func TestRealWorld_GitHubServer(t *testing.T) { + t.Parallel() + + // Create the official ServerJSON format (from the actual registry) + officialFormat := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: "io.github.github/github-mcp-server", + Description: "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", + Version: "0.19.1", + Repository: model.Repository{ + URL: "https://github.com/github/github-mcp-server", + Source: "github", + }, + Packages: []model.Package{ + { + RegistryType: model.RegistryTypeOCI, + Identifier: "ghcr.io/github/github-mcp-server:0.19.1", + Transport: model.Transport{ + Type: model.TransportTypeStdio, + }, + EnvironmentVariables: []model.KeyValueInput{ + { + Name: "GITHUB_PERSONAL_ACCESS_TOKEN", + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: "Your GitHub personal access token with appropriate scopes.", + IsRequired: true, + IsSecret: true, + }, + }, + }, + }, + }, + }, + Meta: &upstream.ServerMeta{ + PublisherProvided: map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + "ghcr.io/github/github-mcp-server:0.19.1": map[string]interface{}{ + "status": "active", + "tier": "Official", + "tools": []interface{}{ + "add_comment_to_pending_review", "add_issue_comment", "add_sub_issue", + "assign_copilot_to_issue", "create_branch", "create_issue", + "create_or_update_file", "create_pull_request", "create_repository", + "delete_file", "fork_repository", "get_commit", "get_file_contents", + "get_issue", "get_issue_comments", "get_label", "get_latest_release", + "get_me", "get_release_by_tag", "get_tag", "get_team_members", + "get_teams", "list_branches", "list_commits", "list_issue_types", + "list_issues", "list_label", "list_pull_requests", "list_releases", + "list_sub_issues", "list_tags", "merge_pull_request", + "pull_request_read", "pull_request_review_write", "push_files", + "remove_sub_issue", "reprioritize_sub_issue", "request_copilot_review", + "search_code", "search_issues", "search_pull_requests", + "search_repositories", "search_users", "update_issue", + "update_pull_request", "update_pull_request_branch", + }, + "tags": []interface{}{ + "api", "create", "fork", "github", "list", + "pull-request", "push", "repository", "search", "update", "issues", + }, + "metadata": map[string]interface{}{ + "stars": float64(23700), + "pulls": float64(5000), + "last_updated": "2025-10-18T02:26:51Z", + }, + }, + }, + }, + }, + } + + // Convert official format to ImageMetadata + imageMetadata, err := ServerJSONToImageMetadata(officialFormat) + require.NoError(t, err, "Should convert official format to ImageMetadata") + require.NotNil(t, imageMetadata) + + // Verify core fields + assert.Equal(t, "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language.", imageMetadata.Description) + assert.Equal(t, "stdio", imageMetadata.Transport) + assert.Equal(t, "ghcr.io/github/github-mcp-server:0.19.1", imageMetadata.Image) + assert.Equal(t, "https://github.com/github/github-mcp-server", imageMetadata.RepositoryURL) + + // Verify environment variables + require.Len(t, imageMetadata.EnvVars, 1) + assert.Equal(t, "GITHUB_PERSONAL_ACCESS_TOKEN", imageMetadata.EnvVars[0].Name) + assert.Equal(t, "Your GitHub personal access token with appropriate scopes.", imageMetadata.EnvVars[0].Description) + assert.True(t, imageMetadata.EnvVars[0].Required) + assert.True(t, imageMetadata.EnvVars[0].Secret) + + // Verify publisher extensions were extracted + assert.Equal(t, "active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + require.Len(t, imageMetadata.Tools, 46, "Should have 46 tools") + assert.Contains(t, imageMetadata.Tools, "create_pull_request") + assert.Contains(t, imageMetadata.Tools, "search_repositories") + require.Len(t, imageMetadata.Tags, 11, "Should have 11 tags") + assert.Contains(t, imageMetadata.Tags, "github") + assert.Contains(t, imageMetadata.Tags, "pull-request") + + // Verify metadata + require.NotNil(t, imageMetadata.Metadata) + assert.Equal(t, 23700, imageMetadata.Metadata.Stars) + assert.Equal(t, 5000, imageMetadata.Metadata.Pulls) + assert.Equal(t, "2025-10-18T02:26:51Z", imageMetadata.Metadata.LastUpdated) + + // Test round-trip: Convert back to ServerJSON + resultServerJSON, err := ImageMetadataToServerJSON("github-mcp-server", imageMetadata) + require.NoError(t, err, "Should convert ImageMetadata back to ServerJSON") + require.NotNil(t, resultServerJSON) + + // Verify round-trip preserved core data + assert.Equal(t, "io.github.stacklok/github-mcp-server", resultServerJSON.Name) + assert.Equal(t, officialFormat.Description, resultServerJSON.Description) + assert.Equal(t, officialFormat.Repository.URL, resultServerJSON.Repository.URL) + + // Verify packages + require.Len(t, resultServerJSON.Packages, 1) + assert.Equal(t, model.RegistryTypeOCI, resultServerJSON.Packages[0].RegistryType) + assert.Equal(t, "ghcr.io/github/github-mcp-server:0.19.1", resultServerJSON.Packages[0].Identifier) + assert.Equal(t, model.TransportTypeStdio, resultServerJSON.Packages[0].Transport.Type) + + // Verify publisher extensions are present in round-trip + require.NotNil(t, resultServerJSON.Meta) + require.NotNil(t, resultServerJSON.Meta.PublisherProvided) + stacklokData, ok := resultServerJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok, "Should have io.github.stacklok namespace") + imageData, ok := stacklokData["ghcr.io/github/github-mcp-server:0.19.1"].(map[string]interface{}) + require.True(t, ok, "Should have image-specific extensions") + + // Verify extensions preserved + assert.Equal(t, "active", imageData["status"]) + assert.Equal(t, "Official", imageData["tier"]) + + // Verify tools are preserved as interface slice + tools, ok := imageData["tools"].([]interface{}) + require.True(t, ok, "Tools should be []interface{}") + assert.Len(t, tools, 46) + + // Verify tags are preserved + tags, ok := imageData["tags"].([]interface{}) + require.True(t, ok, "Tags should be []interface{}") + assert.Len(t, tags, 11) + + // Verify metadata is preserved + metadata, ok := imageData["metadata"].(map[string]interface{}) + require.True(t, ok, "Metadata should be present") + assert.Equal(t, float64(23700), metadata["stars"]) + assert.Equal(t, float64(5000), metadata["pulls"]) + assert.Equal(t, "2025-10-18T02:26:51Z", metadata["last_updated"]) +} + +// TestRealWorld_GitHubServer_ExactData tests conversion using EXACT data from the user +// This uses the actual JSON strings provided to verify visual correctness +func TestRealWorld_GitHubServer_ExactData(t *testing.T) { + t.Parallel() + + // EXACT ImageMetadata format as provided by user (from build/registry.json) + imageMetadataJSON := `{ + "description": "Provides integration with GitHub's APIs", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "metadata": { + "stars": 23700, + "pulls": 5000, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ], + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/github/github-mcp-server", + "signer_identity": "/.github/workflows/docker-publish.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + } +}` + + // Parse ImageMetadata JSON + var imageMetadata registry.ImageMetadata + err := json.Unmarshal([]byte(imageMetadataJSON), &imageMetadata) + require.NoError(t, err, "Should parse ImageMetadata JSON") + + // Log the parsed structure for visual inspection + t.Logf("Parsed ImageMetadata:") + t.Logf(" Description: %s", imageMetadata.Description) + t.Logf(" Image: %s", imageMetadata.Image) + t.Logf(" Status: %s", imageMetadata.Status) + t.Logf(" Tier: %s", imageMetadata.Tier) + t.Logf(" Tools: %d items", len(imageMetadata.Tools)) + t.Logf(" EnvVars: %d items", len(imageMetadata.EnvVars)) + t.Logf(" Tags: %d items", len(imageMetadata.Tags)) + + // Verify parsed data matches expectations + assert.Equal(t, "Provides integration with GitHub's APIs", imageMetadata.Description) + assert.Equal(t, "ghcr.io/github/github-mcp-server:v0.19.1", imageMetadata.Image) + assert.Equal(t, "Active", imageMetadata.Status) + assert.Equal(t, "Official", imageMetadata.Tier) + assert.Equal(t, "stdio", imageMetadata.Transport) + assert.Len(t, imageMetadata.Tools, 46) + assert.Len(t, imageMetadata.EnvVars, 5) + assert.Len(t, imageMetadata.Tags, 11) + assert.NotNil(t, imageMetadata.Permissions) + assert.NotNil(t, imageMetadata.Provenance) + + // Convert to official ServerJSON format + serverJSON, err := ImageMetadataToServerJSON("github", &imageMetadata) + require.NoError(t, err, "Should convert ImageMetadata to ServerJSON") + require.NotNil(t, serverJSON) + + // Marshal to JSON for visual inspection + serverJSONBytes, err := json.MarshalIndent(serverJSON, "", " ") + require.NoError(t, err) + t.Logf("\n\nConverted to Official ServerJSON:\n%s", string(serverJSONBytes)) + + // Verify official format structure + assert.Equal(t, model.CurrentSchemaURL, serverJSON.Schema) + assert.Equal(t, "io.github.stacklok/github", serverJSON.Name) + assert.Equal(t, "Provides integration with GitHub's APIs", serverJSON.Description) + require.Len(t, serverJSON.Packages, 1) + assert.Equal(t, "ghcr.io/github/github-mcp-server:v0.19.1", serverJSON.Packages[0].Identifier) + assert.Len(t, serverJSON.Packages[0].EnvironmentVariables, 5) + + // Verify publisher extensions contain all the extra data + require.NotNil(t, serverJSON.Meta) + require.NotNil(t, serverJSON.Meta.PublisherProvided) + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + require.True(t, ok) + extensions, ok := stacklokData["ghcr.io/github/github-mcp-server:v0.19.1"].(map[string]interface{}) + require.True(t, ok) + + // Verify extensions + assert.Equal(t, "Active", extensions["status"]) + assert.Equal(t, "Official", extensions["tier"]) + assert.NotNil(t, extensions["tools"]) + assert.NotNil(t, extensions["tags"]) + assert.NotNil(t, extensions["metadata"]) + // NOTE: Permissions and provenance would need to be added to the converter functions + // Uncomment once converters.go is updated to include them in publisher extensions: + // assert.NotNil(t, extensions["permissions"]) + // assert.NotNil(t, extensions["provenance"]) + + // Test round-trip: Convert back to ImageMetadata + roundTripImageMetadata, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err, "Should convert ServerJSON back to ImageMetadata") + require.NotNil(t, roundTripImageMetadata) + + // Marshal round-trip result for visual inspection + roundTripBytes, err := json.MarshalIndent(roundTripImageMetadata, "", " ") + require.NoError(t, err) + t.Logf("\n\nRound-trip back to ImageMetadata:\n%s", string(roundTripBytes)) + + // Verify round-trip preserved all data + assert.Equal(t, imageMetadata.Description, roundTripImageMetadata.Description) + assert.Equal(t, imageMetadata.Image, roundTripImageMetadata.Image) + assert.Equal(t, imageMetadata.Status, roundTripImageMetadata.Status) + assert.Equal(t, imageMetadata.Tier, roundTripImageMetadata.Tier) + assert.Equal(t, imageMetadata.Transport, roundTripImageMetadata.Transport) + assert.Equal(t, imageMetadata.RepositoryURL, roundTripImageMetadata.RepositoryURL) + assert.Equal(t, imageMetadata.Tools, roundTripImageMetadata.Tools) + assert.Equal(t, imageMetadata.Tags, roundTripImageMetadata.Tags) + assert.Len(t, roundTripImageMetadata.EnvVars, 5) + + // Verify all 5 env vars + envVarNames := []string{} + for _, ev := range roundTripImageMetadata.EnvVars { + envVarNames = append(envVarNames, ev.Name) + } + assert.Contains(t, envVarNames, "GITHUB_PERSONAL_ACCESS_TOKEN") + assert.Contains(t, envVarNames, "GITHUB_HOST") + assert.Contains(t, envVarNames, "GITHUB_TOOLSETS") + assert.Contains(t, envVarNames, "GITHUB_DYNAMIC_TOOLSETS") + assert.Contains(t, envVarNames, "GITHUB_READ_ONLY") + + // Verify metadata preserved + require.NotNil(t, roundTripImageMetadata.Metadata) + assert.Equal(t, 23700, roundTripImageMetadata.Metadata.Stars) + assert.Equal(t, 5000, roundTripImageMetadata.Metadata.Pulls) + + // NOTE: Permissions and provenance are currently stored in publisher extensions + // but not extracted back during conversion. This is expected behavior for now. + // They are preserved in the ServerJSON._meta but would need extraction logic + // added to converters.go:extractImageExtensions() to be round-tripped. + // + // Uncomment these assertions once extraction logic is added: + // assert.NotNil(t, roundTripImageMetadata.Permissions) + // assert.NotNil(t, roundTripImageMetadata.Provenance) +} diff --git a/pkg/registry/converters/integration_test.go b/pkg/registry/converters/integration_test.go new file mode 100644 index 0000000..02f19f7 --- /dev/null +++ b/pkg/registry/converters/integration_test.go @@ -0,0 +1,412 @@ +package converters + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/stacklok/toolhive/pkg/registry" + "github.com/stretchr/testify/require" +) + +// ToolHiveRegistry represents the structure of registry.json +type ToolHiveRegistry struct { + Servers map[string]json.RawMessage `json:"servers"` + RemoteServers map[string]json.RawMessage `json:"remote_servers"` +} + +// parseServerEntry parses a server entry as either ImageMetadata or RemoteServerMetadata +func parseServerEntry(data json.RawMessage) (imageMetadata *registry.ImageMetadata, remoteMetadata *registry.RemoteServerMetadata, err error) { + // Try to detect type by checking for "image" vs "url" field + var typeCheck map[string]interface{} + if err := json.Unmarshal(data, &typeCheck); err != nil { + return nil, nil, err + } + + if _, hasImage := typeCheck["image"]; hasImage { + // It's an ImageMetadata + var img registry.ImageMetadata + if err := json.Unmarshal(data, &img); err != nil { + return nil, nil, err + } + return &img, nil, nil + } else if _, hasURL := typeCheck["url"]; hasURL { + // It's a RemoteServerMetadata + var remote registry.RemoteServerMetadata + if err := json.Unmarshal(data, &remote); err != nil { + return nil, nil, err + } + return nil, &remote, nil + } + + return nil, nil, fmt.Errorf("unable to determine server type") +} + +// OfficialRegistry represents the structure of official-registry.json +type OfficialRegistry struct { + Data struct { + Servers []upstream.ServerJSON `json:"servers"` + } `json:"data"` +} + +// TestRoundTrip_RealRegistryData tests that we can convert the official registry back to toolhive format +// and that it matches the original registry.json +// Note: This is an integration test that reads from build/ directory, so we don't run it in parallel +// +//nolint:paralleltest // Integration test reads from shared build/ directory +func TestRoundTrip_RealRegistryData(t *testing.T) { + // Skip if running in CI or if files don't exist + officialPath := filepath.Join("..", "..", "..", "build", "official-registry.json") + toolhivePath := filepath.Join("..", "..", "..", "build", "registry.json") + + if _, err := os.Stat(officialPath); os.IsNotExist(err) { + t.Skip("Skipping integration test: official-registry.json not found") + } + if _, err := os.Stat(toolhivePath); os.IsNotExist(err) { + t.Skip("Skipping integration test: registry.json not found") + } + + // Read official registry + officialData, err := os.ReadFile(officialPath) + require.NoError(t, err, "Failed to read official-registry.json") + + var officialRegistry OfficialRegistry + err = json.Unmarshal(officialData, &officialRegistry) + require.NoError(t, err, "Failed to parse official-registry.json") + + // Read toolhive registry + toolhiveData, err := os.ReadFile(toolhivePath) + require.NoError(t, err, "Failed to read registry.json") + + var toolhiveRegistry ToolHiveRegistry + err = json.Unmarshal(toolhiveData, &toolhiveRegistry) + require.NoError(t, err, "Failed to parse registry.json") + + t.Logf("Loaded %d servers from official registry", len(officialRegistry.Data.Servers)) + t.Logf("Loaded %d image servers and %d remote servers from toolhive registry", + len(toolhiveRegistry.Servers), len(toolhiveRegistry.RemoteServers)) + + // Track statistics + stats := struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string + }{} + + // For each server in official registry, convert back and compare + for _, serverJSON := range officialRegistry.Data.Servers { + stats.total++ + + // Extract simple name from reverse DNS format + simpleName := ExtractServerName(serverJSON.Name) + + t.Run(simpleName, func(t *testing.T) { + // Find corresponding entry in toolhive registry (check both servers and remote_servers) + var originalData json.RawMessage + var exists bool + + // Try servers first + originalData, exists = toolhiveRegistry.Servers[simpleName] + if !exists { + // Try remote_servers + originalData, exists = toolhiveRegistry.RemoteServers[simpleName] + if !exists { + t.Logf("āš ļø Server '%s' not found in toolhive registry (checked both servers and remote_servers)", simpleName) + return + } + } + + // Parse the original entry + originalImage, originalRemote, err := parseServerEntry(originalData) + if err != nil { + t.Errorf("āŒ Failed to parse original entry: %v", err) + return + } + + // Determine if it's an image or remote server from official registry + isImage := len(serverJSON.Packages) > 0 + isRemote := len(serverJSON.Remotes) > 0 + + if isImage { + stats.imageServers++ + testImageServerRoundTrip(t, simpleName, &serverJSON, originalImage, &stats) + } else if isRemote { + stats.remoteServers++ + testRemoteServerRoundTrip(t, simpleName, &serverJSON, originalRemote, &stats) + } else { + t.Errorf("āŒ Server '%s' has neither packages nor remotes", simpleName) + } + }) + } + + // Print summary + separator := strings.Repeat("=", 80) + t.Logf("\n%s", separator) + t.Logf("INTEGRATION TEST SUMMARY") + t.Logf("%s", separator) + t.Logf("Total servers: %d", stats.total) + t.Logf(" - Image servers: %d", stats.imageServers) + t.Logf(" - Remote servers: %d", stats.remoteServers) + t.Logf("Conversion errors: %d", stats.conversionErrors) + t.Logf("Field mismatches: %d", len(stats.mismatches)) + + if len(stats.mismatches) > 0 { + t.Logf("\nMismatched fields:") + for _, mismatch := range stats.mismatches { + t.Logf(" - %s", mismatch) + } + } + + if stats.conversionErrors == 0 && len(stats.mismatches) == 0 { + t.Logf("\nāœ… All servers converted successfully with no mismatches!") + } + t.Logf("%s", separator) +} + +func testImageServerRoundTrip(t *testing.T, name string, serverJSON *upstream.ServerJSON, original *registry.ImageMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + if original == nil { + t.Errorf("āŒ Original ImageMetadata is nil for '%s'", name) + return + } + + // Convert ServerJSON back to ImageMetadata + converted, err := ServerJSONToImageMetadata(serverJSON) + if err != nil { + t.Errorf("āŒ Conversion failed: %v", err) + stats.conversionErrors++ + return + } + + // Compare fields + compareImageMetadata(t, name, original, converted, stats) +} + +func testRemoteServerRoundTrip(t *testing.T, name string, serverJSON *upstream.ServerJSON, original *registry.RemoteServerMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + if original == nil { + t.Errorf("āŒ Original RemoteServerMetadata is nil for '%s'", name) + return + } + + // Convert ServerJSON back to RemoteServerMetadata + converted, err := ServerJSONToRemoteServerMetadata(serverJSON) + if err != nil { + t.Errorf("āŒ Conversion failed: %v", err) + stats.conversionErrors++ + return + } + + // Compare fields + compareRemoteServerMetadata(t, name, original, converted, stats) +} + +func compareImageMetadata(t *testing.T, name string, original, converted *registry.ImageMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + // Compare basic fields + if original.Image != converted.Image { + recordMismatch(t, stats, name, "Image", original.Image, converted.Image) + } + if original.Description != converted.Description { + recordMismatch(t, stats, name, "Description", original.Description, converted.Description) + } + if original.Transport != converted.Transport { + recordMismatch(t, stats, name, "Transport", original.Transport, converted.Transport) + } + if original.RepositoryURL != converted.RepositoryURL { + recordMismatch(t, stats, name, "RepositoryURL", original.RepositoryURL, converted.RepositoryURL) + } + if original.Status != converted.Status { + recordMismatch(t, stats, name, "Status", original.Status, converted.Status) + } + if original.Tier != converted.Tier { + recordMismatch(t, stats, name, "Tier", original.Tier, converted.Tier) + } + if original.TargetPort != converted.TargetPort { + recordMismatch(t, stats, name, "TargetPort", original.TargetPort, converted.TargetPort) + } + + // Compare slices + if !stringSlicesEqual(original.Tools, converted.Tools) { + recordMismatch(t, stats, name, "Tools", original.Tools, converted.Tools) + } + if !stringSlicesEqual(original.Tags, converted.Tags) { + recordMismatch(t, stats, name, "Tags", original.Tags, converted.Tags) + } + + // Compare EnvVars + if len(original.EnvVars) != len(converted.EnvVars) { + recordMismatch(t, stats, name, "EnvVars.length", len(original.EnvVars), len(converted.EnvVars)) + } else { + for i := range original.EnvVars { + if !envVarsEqual(original.EnvVars[i], converted.EnvVars[i]) { + recordMismatch(t, stats, name, fmt.Sprintf("EnvVars[%d]", i), original.EnvVars[i], converted.EnvVars[i]) + } + } + } + + // Compare Metadata + if !metadataEqual(original.Metadata, converted.Metadata) { + recordMismatch(t, stats, name, "Metadata", original.Metadata, converted.Metadata) + } + + // Note: Permissions, Provenance, Args are in extensions and may not round-trip perfectly + // We'll log these separately if they differ + if original.Permissions != nil && converted.Permissions == nil { + t.Logf("āš ļø '%s': Permissions not preserved in round-trip", name) + } + if original.Provenance != nil && converted.Provenance == nil { + t.Logf("āš ļø '%s': Provenance not preserved in round-trip", name) + } + if len(original.Args) > 0 && len(converted.Args) == 0 { + t.Logf("āš ļø '%s': Args not preserved in round-trip", name) + } +} + +func compareRemoteServerMetadata(t *testing.T, name string, original, converted *registry.RemoteServerMetadata, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}) { + t.Helper() + // Compare basic fields + if original.URL != converted.URL { + recordMismatch(t, stats, name, "URL", original.URL, converted.URL) + } + if original.Description != converted.Description { + recordMismatch(t, stats, name, "Description", original.Description, converted.Description) + } + if original.Transport != converted.Transport { + recordMismatch(t, stats, name, "Transport", original.Transport, converted.Transport) + } + if original.RepositoryURL != converted.RepositoryURL { + recordMismatch(t, stats, name, "RepositoryURL", original.RepositoryURL, converted.RepositoryURL) + } + if original.Status != converted.Status { + recordMismatch(t, stats, name, "Status", original.Status, converted.Status) + } + if original.Tier != converted.Tier { + recordMismatch(t, stats, name, "Tier", original.Tier, converted.Tier) + } + + // Compare slices + if !stringSlicesEqual(original.Tools, converted.Tools) { + recordMismatch(t, stats, name, "Tools", original.Tools, converted.Tools) + } + if !stringSlicesEqual(original.Tags, converted.Tags) { + recordMismatch(t, stats, name, "Tags", original.Tags, converted.Tags) + } + + // Compare Headers + if len(original.Headers) != len(converted.Headers) { + recordMismatch(t, stats, name, "Headers.length", len(original.Headers), len(converted.Headers)) + } else { + for i := range original.Headers { + if !headersEqual(original.Headers[i], converted.Headers[i]) { + recordMismatch(t, stats, name, fmt.Sprintf("Headers[%d]", i), original.Headers[i], converted.Headers[i]) + } + } + } + + // Compare Metadata + if !metadataEqual(original.Metadata, converted.Metadata) { + recordMismatch(t, stats, name, "Metadata", original.Metadata, converted.Metadata) + } + + // Note: OAuthConfig may not round-trip perfectly + if original.OAuthConfig != nil && converted.OAuthConfig == nil { + t.Logf("āš ļø '%s': OAuthConfig not preserved in round-trip", name) + } +} + +func recordMismatch(t *testing.T, stats *struct { + total int + imageServers int + remoteServers int + conversionErrors int + mismatches []string +}, serverName, field string, original, converted interface{}) { + t.Helper() + msg := fmt.Sprintf("%s.%s: expected %v, got %v", serverName, field, original, converted) + stats.mismatches = append(stats.mismatches, msg) + t.Logf("āš ļø %s", msg) +} + +// Helper comparison functions + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func envVarsEqual(a, b *registry.EnvVar) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Name == b.Name && + a.Description == b.Description && + a.Required == b.Required && + a.Secret == b.Secret && + a.Default == b.Default +} + +func headersEqual(a, b *registry.Header) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Name == b.Name && + a.Description == b.Description && + a.Required == b.Required && + a.Secret == b.Secret +} + +func metadataEqual(a, b *registry.Metadata) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return a.Stars == b.Stars && + a.Pulls == b.Pulls && + a.LastUpdated == b.LastUpdated +} diff --git a/pkg/registry/converters/testdata/README.md b/pkg/registry/converters/testdata/README.md new file mode 100644 index 0000000..fb711c8 --- /dev/null +++ b/pkg/registry/converters/testdata/README.md @@ -0,0 +1,174 @@ +# Converter Test Fixtures + +This directory contains JSON fixture files for testing the converter functions between ToolHive ImageMetadata/RemoteServerMetadata and official MCP ServerJSON formats. + +## Directory Structure + +``` +testdata/ +ā”œā”€ā”€ image_to_server/ # ImageMetadata → ServerJSON conversions +ā”œā”€ā”€ server_to_image/ # ServerJSON → ImageMetadata conversions +ā”œā”€ā”€ remote_to_server/ # RemoteServerMetadata → ServerJSON conversions +└── server_to_remote/ # ServerJSON → RemoteServerMetadata conversions +``` + +Each directory contains: +- `input_*.json` - Input data for the conversion +- `expected_*.json` - Expected output after conversion + +## Test Coverage + +### Image-based Servers + +**GitHub Server** (`image_to_server/input_github.json`) +- Full production example with 46 tools +- 5 environment variables (including optional ones) +- Permissions and provenance metadata +- Demonstrates complete ToolHive → Official MCP conversion + +**Round-trip** (`server_to_image/`) +- Uses the output from `image_to_server` as input +- Validates bidirectional conversion without data loss +- Ensures all fields are preserved through the conversion cycle + +### Remote Servers + +**Example Remote** (`remote_to_server/input_example.json`) +- SSE transport type +- Multiple headers (required and optional) +- Demonstrates remote server conversion pattern + +**Round-trip** (`server_to_remote/`) +- Validates remote server bidirectional conversion +- Ensures headers and metadata are preserved + +## Usage in Tests + +The fixtures are used by `converters_fixture_test.go`: + +```go +func TestConverters_Fixtures(t *testing.T) { + // Table-driven test that: + // 1. Loads input fixture + // 2. Runs conversion + // 3. Compares with expected output + // 4. Runs additional validation checks +} +``` + +## Adding New Test Cases + +To add a new test case: + +1. **Create input file** in the appropriate directory: + ```bash + # For image-based server + vi testdata/image_to_server/input_myserver.json + ``` + +2. **Generate expected output** using the converter: + ```go + // Example code to generate expected output + imageMetadata := loadFromFile("input_myserver.json") + serverJSON, _ := ImageMetadataToServerJSON("myserver", imageMetadata) + saveToFile("expected_myserver.json", serverJSON) + ``` + +3. **Add test case** to `converters_fixture_test.go`: + ```go + { + name: "ImageMetadata to ServerJSON - MyServer", + fixtureDir: "testdata/image_to_server", + inputFile: "input_myserver.json", + expectedFile: "expected_myserver.json", + serverName: "myserver", + convertFunc: "ImageToServer", + validateFunc: validateImageToServerConversion, + }, + ``` + +## Fixture Format Examples + +### ImageMetadata Format +```json +{ + "description": "Server description", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": ["tool1", "tool2"], + "image": "ghcr.io/org/server:v1.0.0", + "env_vars": [...], + "permissions": {...}, + "provenance": {...} +} +``` + +### ServerJSON Format +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/server", + "description": "Server description", + "version": "1.0.0", + "packages": [{ + "registryType": "oci", + "identifier": "ghcr.io/org/server:v1.0.0", + "transport": {"type": "stdio"}, + "environmentVariables": [...] + }], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/org/server:v1.0.0": { + "status": "Active", + "tier": "Official", + "tools": [...], + ... + } + } + } + } +} +``` + +### RemoteServerMetadata Format +```json +{ + "description": "Remote server description", + "transport": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Auth header", + "required": true, + "secret": true + } + ], + "tools": [...], + "tags": [...] +} +``` + +## Benefits of Fixture-based Testing + +1. **Visual Inspection** - Easy to see exactly what data is being transformed +2. **Maintainability** - Update fixtures without changing test code +3. **Documentation** - Fixtures serve as examples of expected formats +4. **Regression Detection** - Any changes to output format are immediately visible +5. **Multiple Scenarios** - Easy to add edge cases and variants + +## Regenerating Fixtures + +If the converter logic changes and you need to regenerate expected outputs: + +```bash +# Regenerate all expected outputs +go run scripts/regenerate_fixtures.go + +# Or regenerate specific ones +go run scripts/regenerate_fixtures.go --type image_to_server --name github +``` + +(Note: Create the regenerate script if needed) \ No newline at end of file diff --git a/pkg/registry/converters/testdata/image_to_server/expected_github.json b/pkg/registry/converters/testdata/image_to_server/expected_github.json new file mode 100644 index 0000000..f86593b --- /dev/null +++ b/pkg/registry/converters/testdata/image_to_server/expected_github.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/github", + "description": "Provides integration with GitHub's APIs", + "repository": { + "url": "https://github.com/github/github-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:v0.19.1", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "description": "GitHub personal access token with appropriate permissions", + "isRequired": true, + "isSecret": true, + "name": "GITHUB_PERSONAL_ACCESS_TOKEN" + }, + { + "description": "GitHub Enterprise Server hostname (optional)", + "name": "GITHUB_HOST" + }, + { + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "name": "GITHUB_TOOLSETS" + }, + { + "description": "Set to '1' to enable dynamic toolset discovery", + "name": "GITHUB_DYNAMIC_TOOLSETS" + }, + { + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "name": "GITHUB_READ_ONLY" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/github/github-mcp-server:v0.19.1": { + "metadata": { + "last_updated": "2025-10-18T02:26:51Z", + "pulls": 5000, + "stars": 23700 + }, + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "provenance": { + "cert_issuer": "https://token.actions.githubusercontent.com", + "repository_uri": "https://github.com/github/github-mcp-server", + "runner_environment": "github-hosted", + "signer_identity": "/.github/workflows/docker-publish.yml", + "sigstore_url": "tuf-repo-cdn.sigstore.dev" + }, + "status": "Active", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "tier": "Official", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ] + } + } + } + } +} diff --git a/pkg/registry/converters/testdata/image_to_server/input_github.json b/pkg/registry/converters/testdata/image_to_server/input_github.json new file mode 100644 index 0000000..718efe4 --- /dev/null +++ b/pkg/registry/converters/testdata/image_to_server/input_github.json @@ -0,0 +1,122 @@ +{ + "description": "Provides integration with GitHub's APIs", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "metadata": { + "stars": 23700, + "pulls": 5000, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ], + "provenance": { + "sigstore_url": "tuf-repo-cdn.sigstore.dev", + "repository_uri": "https://github.com/github/github-mcp-server", + "signer_identity": "/.github/workflows/docker-publish.yml", + "runner_environment": "github-hosted", + "cert_issuer": "https://token.actions.githubusercontent.com" + } +} diff --git a/pkg/registry/converters/testdata/remote_to_server/expected_example.json b/pkg/registry/converters/testdata/remote_to_server/expected_example.json new file mode 100644 index 0000000..9c40f73 --- /dev/null +++ b/pkg/registry/converters/testdata/remote_to_server/expected_example.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/example-remote", + "description": "Example remote MCP server accessed via SSE", + "repository": { + "url": "https://github.com/example/remote-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "remotes": [ + { + "type": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "description": "Bearer token for API authentication", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + }, + { + "description": "API version to use", + "name": "X-API-Version" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "https://api.example.com/mcp": { + "metadata": { + "last_updated": "2025-10-20T10:00:00Z", + "pulls": 500, + "stars": 150 + }, + "status": "active", + "tags": [ + "remote", + "sse", + "api" + ], + "tier": "Community", + "tools": [ + "get_data", + "send_notification", + "query_api" + ] + } + } + } + } +} diff --git a/pkg/registry/converters/testdata/remote_to_server/input_example.json b/pkg/registry/converters/testdata/remote_to_server/input_example.json new file mode 100644 index 0000000..24b06f5 --- /dev/null +++ b/pkg/registry/converters/testdata/remote_to_server/input_example.json @@ -0,0 +1,36 @@ +{ + "description": "Example remote MCP server accessed via SSE", + "tier": "Community", + "status": "active", + "transport": "sse", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "metadata": { + "stars": 150, + "pulls": 500, + "last_updated": "2025-10-20T10:00:00Z" + }, + "repository_url": "https://github.com/example/remote-mcp-server", + "tags": [ + "remote", + "sse", + "api" + ], + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for API authentication", + "required": true, + "secret": true + }, + { + "name": "X-API-Version", + "description": "API version to use", + "required": false + } + ] +} diff --git a/pkg/registry/converters/testdata/server_to_image/expected_github.json b/pkg/registry/converters/testdata/server_to_image/expected_github.json new file mode 100644 index 0000000..875d3ec --- /dev/null +++ b/pkg/registry/converters/testdata/server_to_image/expected_github.json @@ -0,0 +1,102 @@ +{ + "description": "Provides integration with GitHub's APIs", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "metadata": { + "stars": 23700, + "pulls": 5000, + "last_updated": "2025-10-18T02:26:51Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "image": "ghcr.io/github/github-mcp-server:v0.19.1", + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + }, + { + "name": "GITHUB_HOST", + "description": "GitHub Enterprise Server hostname (optional)", + "required": false + }, + { + "name": "GITHUB_TOOLSETS", + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "required": false + }, + { + "name": "GITHUB_DYNAMIC_TOOLSETS", + "description": "Set to '1' to enable dynamic toolset discovery", + "required": false + }, + { + "name": "GITHUB_READ_ONLY", + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "required": false + } + ] +} diff --git a/pkg/registry/converters/testdata/server_to_image/input_github.json b/pkg/registry/converters/testdata/server_to_image/input_github.json new file mode 100644 index 0000000..1fc2cbc --- /dev/null +++ b/pkg/registry/converters/testdata/server_to_image/input_github.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/github", + "description": "Provides integration with GitHub's APIs", + "repository": { + "url": "https://github.com/github/github-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:v0.19.1", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "description": "GitHub personal access token with appropriate permissions", + "isRequired": true, + "isSecret": true, + "name": "GITHUB_PERSONAL_ACCESS_TOKEN" + }, + { + "description": "GitHub Enterprise Server hostname (optional)", + "name": "GITHUB_HOST" + }, + { + "description": "Comma-separated list of toolsets to enable (e.g., 'repos,issues,pull_requests'). If not set, all toolsets are enabled. See the README for available toolsets.", + "name": "GITHUB_TOOLSETS" + }, + { + "description": "Set to '1' to enable dynamic toolset discovery", + "name": "GITHUB_DYNAMIC_TOOLSETS" + }, + { + "description": "Set to '1' to enable read-only mode, preventing any modifications to GitHub resources", + "name": "GITHUB_READ_ONLY" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "ghcr.io/github/github-mcp-server:v0.19.1": { + "metadata": { + "last_updated": "2025-10-18T02:26:51Z", + "pulls": 5000, + "stars": 23700 + }, + "status": "Active", + "tags": [ + "api", + "create", + "fork", + "github", + "list", + "pull-request", + "push", + "repository", + "search", + "update", + "issues" + ], + "tier": "Official", + "tools": [ + "add_comment_to_pending_review", + "add_issue_comment", + "add_sub_issue", + "assign_copilot_to_issue", + "create_branch", + "create_issue", + "create_or_update_file", + "create_pull_request", + "create_repository", + "delete_file", + "fork_repository", + "get_commit", + "get_file_contents", + "get_issue", + "get_issue_comments", + "get_label", + "get_latest_release", + "get_me", + "get_release_by_tag", + "get_tag", + "get_team_members", + "get_teams", + "list_branches", + "list_commits", + "list_issue_types", + "list_issues", + "list_label", + "list_pull_requests", + "list_releases", + "list_sub_issues", + "list_tags", + "merge_pull_request", + "pull_request_read", + "pull_request_review_write", + "push_files", + "remove_sub_issue", + "reprioritize_sub_issue", + "request_copilot_review", + "search_code", + "search_issues", + "search_pull_requests", + "search_repositories", + "search_users", + "update_issue", + "update_pull_request", + "update_pull_request_branch" + ], + "transport": "stdio" + } + } + } + } +} diff --git a/pkg/registry/converters/testdata/server_to_remote/expected_example.json b/pkg/registry/converters/testdata/server_to_remote/expected_example.json new file mode 100644 index 0000000..24b06f5 --- /dev/null +++ b/pkg/registry/converters/testdata/server_to_remote/expected_example.json @@ -0,0 +1,36 @@ +{ + "description": "Example remote MCP server accessed via SSE", + "tier": "Community", + "status": "active", + "transport": "sse", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "metadata": { + "stars": 150, + "pulls": 500, + "last_updated": "2025-10-20T10:00:00Z" + }, + "repository_url": "https://github.com/example/remote-mcp-server", + "tags": [ + "remote", + "sse", + "api" + ], + "url": "https://api.example.com/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer token for API authentication", + "required": true, + "secret": true + }, + { + "name": "X-API-Version", + "description": "API version to use", + "required": false + } + ] +} diff --git a/pkg/registry/converters/testdata/server_to_remote/input_example.json b/pkg/registry/converters/testdata/server_to_remote/input_example.json new file mode 100644 index 0000000..91dd52c --- /dev/null +++ b/pkg/registry/converters/testdata/server_to_remote/input_example.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.stacklok/example-remote", + "description": "Example remote MCP server accessed via SSE", + "repository": { + "url": "https://github.com/example/remote-mcp-server", + "source": "github" + }, + "version": "1.0.0", + "remotes": [ + { + "type": "sse", + "url": "https://api.example.com/mcp", + "headers": [ + { + "description": "Bearer token for API authentication", + "isRequired": true, + "isSecret": true, + "name": "Authorization" + }, + { + "description": "API version to use", + "name": "X-API-Version" + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.github.stacklok": { + "https://api.example.com/mcp": { + "metadata": { + "last_updated": "2025-10-20T10:00:00Z", + "pulls": 500, + "stars": 150 + }, + "status": "active", + "tags": [ + "remote", + "sse", + "api" + ], + "tier": "Community", + "tools": [ + "get_data", + "send_notification", + "query_api" + ], + "transport": "sse" + } + } + } + } +} diff --git a/pkg/registry/converters/toolhive_to_upstream.go b/pkg/registry/converters/toolhive_to_upstream.go new file mode 100644 index 0000000..c68f7c8 --- /dev/null +++ b/pkg/registry/converters/toolhive_to_upstream.go @@ -0,0 +1,309 @@ +// Package converters provides bidirectional conversion between toolhive registry formats +// and the upstream MCP (Model Context Protocol) ServerJSON format. +// +// The package supports two conversion directions: +// - toolhive → upstream: ImageMetadata/RemoteServerMetadata → ServerJSON (this file) +// - upstream → toolhive: ServerJSON → ImageMetadata/RemoteServerMetadata (upstream_to_toolhive.go) +// +// Toolhive-specific fields (permissions, provenance, metadata) are stored in the upstream +// format's publisher extensions under "io.github.stacklok", allowing additional metadata +// while maintaining compatibility with the standard MCP registry format. +package converters + +import ( + "fmt" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stacklok/toolhive/pkg/registry" +) + +// ImageMetadataToServerJSON converts toolhive ImageMetadata to an upstream ServerJSON +// The name parameter should be the simple server name (e.g., "fetch") +func ImageMetadataToServerJSON(name string, imageMetadata *registry.ImageMetadata) (*upstream.ServerJSON, error) { + if imageMetadata == nil { + return nil, fmt.Errorf("imageMetadata cannot be nil") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + + // Create ServerJSON with basic fields + serverJSON := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: BuildReverseDNSName(name), + Title: imageMetadata.Name, + Description: imageMetadata.Description, + Version: "1.0.0", // TODO: Extract from image tag or metadata + } + + // Set repository + if imageMetadata.RepositoryURL != "" { + serverJSON.Repository = model.Repository{ + URL: imageMetadata.RepositoryURL, + Source: "github", // Assume GitHub + } + } else { + // Use toolhive-registry as fallback when no repository URL is available. + // This is necessary for schema validation - the upstream Repository field is a struct + // (not a pointer), so it can't be omitted with omitempty and would serialize as + // empty strings {"url": "", "source": ""}, which fails URI format validation. + // Using the toolhive-registry URL is reasonable since it's where these servers + // are registered and documented. + serverJSON.Repository = model.Repository{ + URL: "https://github.com/stacklok/toolhive-registry", + Source: "github", + } + } + + // Create package + serverJSON.Packages = createPackagesFromImageMetadata(imageMetadata) + + // Create publisher extensions + serverJSON.Meta = &upstream.ServerMeta{ + PublisherProvided: createImageExtensions(imageMetadata), + } + + return serverJSON, nil +} + +// RemoteServerMetadataToServerJSON converts toolhive RemoteServerMetadata to an upstream ServerJSON +// The name parameter should be the simple server name (e.g., "github-remote") +func RemoteServerMetadataToServerJSON(name string, remoteMetadata *registry.RemoteServerMetadata) (*upstream.ServerJSON, error) { + if remoteMetadata == nil { + return nil, fmt.Errorf("remoteMetadata cannot be nil") + } + if name == "" { + return nil, fmt.Errorf("name cannot be empty") + } + + // Create ServerJSON with basic fields + serverJSON := &upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: BuildReverseDNSName(name), + Title: remoteMetadata.Name, + Description: remoteMetadata.Description, + Version: "1.0.0", // TODO: Version management + } + + // Set repository + if remoteMetadata.RepositoryURL != "" { + serverJSON.Repository = model.Repository{ + URL: remoteMetadata.RepositoryURL, + Source: "github", // Assume GitHub + } + } else { + // Use toolhive-registry as fallback when no repository URL is available. + // This is necessary for schema validation - the upstream Repository field is a struct + // (not a pointer), so it can't be omitted with omitempty and would serialize as + // empty strings {"url": "", "source": ""}, which fails URI format validation. + // Using the toolhive-registry URL is reasonable since it's where these servers + // are registered and documented. + serverJSON.Repository = model.Repository{ + URL: "https://github.com/stacklok/toolhive-registry", + Source: "github", + } + } + + // Create remote + serverJSON.Remotes = createRemotesFromRemoteMetadata(remoteMetadata) + + // Create publisher extensions + serverJSON.Meta = &upstream.ServerMeta{ + PublisherProvided: createRemoteExtensions(remoteMetadata), + } + + return serverJSON, nil +} + +// createPackagesFromImageMetadata creates OCI Package entries from ImageMetadata +func createPackagesFromImageMetadata(imageMetadata *registry.ImageMetadata) []model.Package { + // Convert environment variables + var envVars []model.KeyValueInput + for _, envVar := range imageMetadata.EnvVars { + envVars = append(envVars, model.KeyValueInput{ + Name: envVar.Name, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: envVar.Description, + IsRequired: envVar.Required, + IsSecret: envVar.Secret, + Default: envVar.Default, + }, + }, + }) + } + + // Determine transport + transportType := imageMetadata.Transport + if transportType == "" { + transportType = model.TransportTypeStdio + } + + transport := model.Transport{ + Type: transportType, + } + + // Add URL for non-stdio transports + // Note: We use localhost as the host because container-based MCP servers run locally + // and are accessed via port forwarding. The actual container may listen on 0.0.0.0, + // but clients connect via localhost on the host machine. + if transportType == model.TransportTypeStreamableHTTP || transportType == model.TransportTypeSSE { + if imageMetadata.TargetPort > 0 { + // Include port in URL if explicitly set + transport.URL = fmt.Sprintf("http://localhost:%d", imageMetadata.TargetPort) + } else { + // No port specified - use URL without port (standard HTTP port 80) + transport.URL = "http://localhost" + } + } + + return []model.Package{{ + RegistryType: model.RegistryTypeOCI, + Identifier: imageMetadata.Image, + EnvironmentVariables: envVars, + Transport: transport, + }} +} + +// createRemotesFromRemoteMetadata creates Transport entries from RemoteServerMetadata +func createRemotesFromRemoteMetadata(remoteMetadata *registry.RemoteServerMetadata) []model.Transport { + // Convert headers + var headers []model.KeyValueInput + for _, header := range remoteMetadata.Headers { + headers = append(headers, model.KeyValueInput{ + Name: header.Name, + InputWithVariables: model.InputWithVariables{ + Input: model.Input{ + Description: header.Description, + IsRequired: header.Required, + IsSecret: header.Secret, + }, + }, + }) + } + + return []model.Transport{{ + Type: remoteMetadata.Transport, + URL: remoteMetadata.URL, + Headers: headers, + }} +} + +// createImageExtensions creates publisher extensions map from ImageMetadata +func createImageExtensions(imageMetadata *registry.ImageMetadata) map[string]interface{} { + extensions := make(map[string]interface{}) + + // Always include status + extensions["status"] = imageMetadata.Status + if extensions["status"] == "" { + extensions["status"] = "active" + } + + // Add tools + if len(imageMetadata.Tools) > 0 { + tools := make([]interface{}, len(imageMetadata.Tools)) + for i, tool := range imageMetadata.Tools { + tools[i] = tool + } + extensions["tools"] = tools + } + + // Add tier + if imageMetadata.Tier != "" { + extensions["tier"] = imageMetadata.Tier + } + + // Add tags + if len(imageMetadata.Tags) > 0 { + tags := make([]interface{}, len(imageMetadata.Tags)) + for i, tag := range imageMetadata.Tags { + tags[i] = tag + } + extensions["tags"] = tags + } + + // Add metadata + if imageMetadata.Metadata != nil { + extensions["metadata"] = map[string]interface{}{ + "stars": float64(imageMetadata.Metadata.Stars), + "pulls": float64(imageMetadata.Metadata.Pulls), + "last_updated": imageMetadata.Metadata.LastUpdated, + } + } + + // Add permissions + if imageMetadata.Permissions != nil { + extensions["permissions"] = imageMetadata.Permissions + } + + // Add args (static container arguments) + if len(imageMetadata.Args) > 0 { + extensions["args"] = imageMetadata.Args + } + + // Add provenance + if imageMetadata.Provenance != nil { + extensions["provenance"] = imageMetadata.Provenance + } + + return map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + imageMetadata.Image: extensions, + }, + } +} + +// createRemoteExtensions creates publisher extensions map from RemoteServerMetadata +func createRemoteExtensions(remoteMetadata *registry.RemoteServerMetadata) map[string]interface{} { + extensions := make(map[string]interface{}) + + // Always include status + extensions["status"] = remoteMetadata.Status + if extensions["status"] == "" { + extensions["status"] = "active" + } + + // Add tools + if len(remoteMetadata.Tools) > 0 { + tools := make([]interface{}, len(remoteMetadata.Tools)) + for i, tool := range remoteMetadata.Tools { + tools[i] = tool + } + extensions["tools"] = tools + } + + // Add tier + if remoteMetadata.Tier != "" { + extensions["tier"] = remoteMetadata.Tier + } + + // Add tags + if len(remoteMetadata.Tags) > 0 { + tags := make([]interface{}, len(remoteMetadata.Tags)) + for i, tag := range remoteMetadata.Tags { + tags[i] = tag + } + extensions["tags"] = tags + } + + // Add metadata + if remoteMetadata.Metadata != nil { + extensions["metadata"] = map[string]interface{}{ + "stars": float64(remoteMetadata.Metadata.Stars), + "pulls": float64(remoteMetadata.Metadata.Pulls), + "last_updated": remoteMetadata.Metadata.LastUpdated, + } + } + + // Add OAuth config + if remoteMetadata.OAuthConfig != nil { + extensions["oauth_config"] = remoteMetadata.OAuthConfig + } + + return map[string]interface{}{ + "io.github.stacklok": map[string]interface{}{ + remoteMetadata.URL: extensions, + }, + } +} diff --git a/pkg/registry/converters/upstream_to_toolhive.go b/pkg/registry/converters/upstream_to_toolhive.go new file mode 100644 index 0000000..8240031 --- /dev/null +++ b/pkg/registry/converters/upstream_to_toolhive.go @@ -0,0 +1,323 @@ +// Package converters provides conversion functions from upstream MCP ServerJSON format +// to toolhive ImageMetadata/RemoteServerMetadata formats. +package converters + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + + upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stacklok/toolhive/pkg/permissions" + "github.com/stacklok/toolhive/pkg/registry" +) + +// ServerJSONToImageMetadata converts an upstream ServerJSON (with OCI packages) to toolhive ImageMetadata +// This function only handles OCI packages and will error if there are multiple OCI packages +func ServerJSONToImageMetadata(serverJSON *upstream.ServerJSON) (*registry.ImageMetadata, error) { + if serverJSON == nil { + return nil, fmt.Errorf("serverJSON cannot be nil") + } + + if len(serverJSON.Packages) == 0 { + return nil, fmt.Errorf("server '%s' has no packages (not a container-based server)", serverJSON.Name) + } + + // Filter for OCI packages only + var ociPackages []model.Package + var packageTypes []string + for _, pkg := range serverJSON.Packages { + if pkg.RegistryType == model.RegistryTypeOCI { + ociPackages = append(ociPackages, pkg) + } + packageTypes = append(packageTypes, string(pkg.RegistryType)) + } + + if len(ociPackages) == 0 { + return nil, fmt.Errorf("server '%s' has no OCI packages (found: %v)", serverJSON.Name, packageTypes) + } + + if len(ociPackages) > 1 { + return nil, fmt.Errorf("server '%s' has %d OCI packages, expected exactly 1", serverJSON.Name, len(ociPackages)) + } + + pkg := ociPackages[0] + + imageMetadata := ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Name: serverJSON.Title, + Description: serverJSON.Description, + Transport: pkg.Transport.Type, + }, + Image: pkg.Identifier, // OCI packages store full image ref in Identifier + } + + // Set repository URL + if serverJSON.Repository.URL != "" { + imageMetadata.RepositoryURL = serverJSON.Repository.URL + } + + // Convert environment variables + if len(pkg.EnvironmentVariables) > 0 { + imageMetadata.EnvVars = make([]*registry.EnvVar, 0, len(pkg.EnvironmentVariables)) + for _, envVar := range pkg.EnvironmentVariables { + imageMetadata.EnvVars = append(imageMetadata.EnvVars, ®istry.EnvVar{ + Name: envVar.Name, + Description: envVar.Description, + Required: envVar.IsRequired, + Secret: envVar.IsSecret, + Default: envVar.Default, + }) + } + } + + // Extract target port from transport URL if present + if pkg.Transport.URL != "" { + // Parse URL like "http://localhost:8080" + parsedURL, err := url.Parse(pkg.Transport.URL) + if err != nil { + // Log parse error to aid debugging but don't fail conversion + fmt.Printf("āš ļø Failed to parse transport URL '%s' for server '%s': %v\n", + pkg.Transport.URL, serverJSON.Name, err) + } else if parsedURL.Port() != "" { + if port, err := strconv.Atoi(parsedURL.Port()); err == nil { + imageMetadata.TargetPort = port + } else { + fmt.Printf("āš ļø Failed to parse port from URL '%s' for server '%s': %v\n", + pkg.Transport.URL, serverJSON.Name, err) + } + } + } + + // Convert PackageArguments to simple Args (priority: structured arguments first) + if len(pkg.PackageArguments) > 0 { + imageMetadata.Args = flattenPackageArguments(pkg.PackageArguments) + } + + // Extract publisher-provided extensions (including Args fallback) + extractImageExtensions(serverJSON, imageMetadata) + + return imageMetadata, nil +} + +// ServerJSONToRemoteServerMetadata converts an upstream ServerJSON (with remotes) to toolhive RemoteServerMetadata +// This function extracts remote server data and reconstructs RemoteServerMetadata format +func ServerJSONToRemoteServerMetadata(serverJSON *upstream.ServerJSON) (*registry.RemoteServerMetadata, error) { + if serverJSON == nil { + return nil, fmt.Errorf("serverJSON cannot be nil") + } + + if len(serverJSON.Remotes) == 0 { + return nil, fmt.Errorf("server '%s' has no remotes (not a remote server)", serverJSON.Name) + } + + remote := serverJSON.Remotes[0] // Use first remote + + remoteMetadata := ®istry.RemoteServerMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Name: serverJSON.Title, + Description: serverJSON.Description, + Transport: remote.Type, + }, + URL: remote.URL, + } + + // Set repository URL + if serverJSON.Repository.URL != "" { + remoteMetadata.RepositoryURL = serverJSON.Repository.URL + } + + // Convert headers + if len(remote.Headers) > 0 { + remoteMetadata.Headers = make([]*registry.Header, 0, len(remote.Headers)) + for _, header := range remote.Headers { + remoteMetadata.Headers = append(remoteMetadata.Headers, ®istry.Header{ + Name: header.Name, + Description: header.Description, + Required: header.IsRequired, + Secret: header.IsSecret, + }) + } + } + + // Extract publisher-provided extensions + extractRemoteExtensions(serverJSON, remoteMetadata) + + return remoteMetadata, nil +} + +// extractImageExtensions extracts publisher-provided extensions into ImageMetadata +func extractImageExtensions(serverJSON *upstream.ServerJSON, imageMetadata *registry.ImageMetadata) { + extensions := getStacklokExtensions(serverJSON) + if extensions == nil { + return + } + + extractBasicImageFields(extensions, imageMetadata) + extractImageMetadataField(extensions, imageMetadata) + extractComplexImageFields(extensions, imageMetadata) +} + +// getStacklokExtensions retrieves the first stacklok extension data from ServerJSON +func getStacklokExtensions(serverJSON *upstream.ServerJSON) map[string]interface{} { + if serverJSON.Meta == nil || serverJSON.Meta.PublisherProvided == nil { + return nil + } + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + if !ok { + return nil + } + + // Return first extension data (keyed by image reference or URL) + for _, extensionsData := range stacklokData { + if extensions, ok := extensionsData.(map[string]interface{}); ok { + return extensions + } + } + return nil +} + +// extractBasicImageFields extracts basic string and slice fields +func extractBasicImageFields(extensions map[string]interface{}, imageMetadata *registry.ImageMetadata) { + if status, ok := extensions["status"].(string); ok { + imageMetadata.Status = status + } + if tier, ok := extensions["tier"].(string); ok { + imageMetadata.Tier = tier + } + if toolsData, ok := extensions["tools"].([]interface{}); ok { + imageMetadata.Tools = interfaceSliceToStringSlice(toolsData) + } + if tagsData, ok := extensions["tags"].([]interface{}); ok { + imageMetadata.Tags = interfaceSliceToStringSlice(tagsData) + } +} + +// extractImageMetadataField extracts the metadata object (stars, pulls, last_updated) +func extractImageMetadataField(extensions map[string]interface{}, imageMetadata *registry.ImageMetadata) { + metadataData, ok := extensions["metadata"].(map[string]interface{}) + if !ok { + return + } + + imageMetadata.Metadata = ®istry.Metadata{} + if stars, ok := metadataData["stars"].(float64); ok { + imageMetadata.Metadata.Stars = int(stars) + } + if pulls, ok := metadataData["pulls"].(float64); ok { + imageMetadata.Metadata.Pulls = int(pulls) + } + if lastUpdated, ok := metadataData["last_updated"].(string); ok { + imageMetadata.Metadata.LastUpdated = lastUpdated + } +} + +// extractComplexImageFields extracts complex fields (args, permissions, provenance) +func extractComplexImageFields(extensions map[string]interface{}, imageMetadata *registry.ImageMetadata) { + // Extract args (fallback if PackageArguments wasn't used) + if len(imageMetadata.Args) == 0 { + if argsData, ok := extensions["args"].([]interface{}); ok { + imageMetadata.Args = interfaceSliceToStringSlice(argsData) + } + } + + // Extract permissions using JSON round-trip + if permsData, ok := extensions["permissions"]; ok { + imageMetadata.Permissions = remarshalToType[*permissions.Profile](permsData) + } + + // Extract provenance using JSON round-trip + if provData, ok := extensions["provenance"]; ok { + imageMetadata.Provenance = remarshalToType[*registry.Provenance](provData) + } +} + +// extractRemoteExtensions extracts publisher-provided extensions into RemoteServerMetadata +func extractRemoteExtensions(serverJSON *upstream.ServerJSON, remoteMetadata *registry.RemoteServerMetadata) { + if serverJSON.Meta == nil || serverJSON.Meta.PublisherProvided == nil { + return + } + + stacklokData, ok := serverJSON.Meta.PublisherProvided["io.github.stacklok"].(map[string]interface{}) + if !ok { + return + } + + // Find the extension data (keyed by URL) + for _, extensionsData := range stacklokData { + extensions, ok := extensionsData.(map[string]interface{}) + if !ok { + continue + } + + // Extract fields + if status, ok := extensions["status"].(string); ok { + remoteMetadata.Status = status + } + if tier, ok := extensions["tier"].(string); ok { + remoteMetadata.Tier = tier + } + if toolsData, ok := extensions["tools"].([]interface{}); ok { + remoteMetadata.Tools = interfaceSliceToStringSlice(toolsData) + } + if tagsData, ok := extensions["tags"].([]interface{}); ok { + remoteMetadata.Tags = interfaceSliceToStringSlice(tagsData) + } + if metadataData, ok := extensions["metadata"].(map[string]interface{}); ok { + remoteMetadata.Metadata = ®istry.Metadata{} + if stars, ok := metadataData["stars"].(float64); ok { + remoteMetadata.Metadata.Stars = int(stars) + } + if pulls, ok := metadataData["pulls"].(float64); ok { + remoteMetadata.Metadata.Pulls = int(pulls) + } + if lastUpdated, ok := metadataData["last_updated"].(string); ok { + remoteMetadata.Metadata.LastUpdated = lastUpdated + } + } + + // Extract OAuth config using JSON round-trip + if oauthData, ok := extensions["oauth_config"]; ok { + remoteMetadata.OAuthConfig = remarshalToType[*registry.OAuthConfig](oauthData) + } + + break // Only process first entry + } +} + +// remarshalToType converts an interface{} value to a specific type using JSON marshaling +// This is useful for deserializing complex nested structures from extensions +func remarshalToType[T any](data interface{}) T { + var result T + + // Marshal to JSON + jsonData, err := json.Marshal(data) + if err != nil { + return result // Return zero value on error + } + + // Unmarshal into target type + _ = json.Unmarshal(jsonData, &result) // Ignore error, return zero value if fails + + return result +} + +// flattenPackageArguments converts structured PackageArguments to simple string Args +// This provides better interoperability when importing from upstream sources +func flattenPackageArguments(args []model.Argument) []string { + var result []string + for _, arg := range args { + // Add the argument name/flag if present + if arg.Name != "" { + result = append(result, arg.Name) + } + // Add the value if present (for named args with values or positional args) + if arg.Value != "" { + result = append(result, arg.Value) + } + } + return result +} diff --git a/pkg/registry/converters/utils.go b/pkg/registry/converters/utils.go new file mode 100644 index 0000000..ee14057 --- /dev/null +++ b/pkg/registry/converters/utils.go @@ -0,0 +1,36 @@ +// Package converters provides utility functions for conversion between upstream and toolhive formats. +package converters + +import ( + "strings" +) + +// interfaceSliceToStringSlice converts []interface{} to []string +func interfaceSliceToStringSlice(input []interface{}) []string { + result := make([]string, 0, len(input)) + for _, item := range input { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result +} + +// ExtractServerName extracts the simple server name from a reverse-DNS format name +// Example: "io.github.stacklok/fetch" -> "fetch" +func ExtractServerName(reverseDNSName string) string { + parts := strings.Split(reverseDNSName, "/") + if len(parts) == 2 { + return parts[1] + } + return reverseDNSName +} + +// BuildReverseDNSName builds a reverse-DNS format name from a simple name +// Example: "fetch" -> "io.github.stacklok/fetch" +func BuildReverseDNSName(simpleName string) string { + if strings.Contains(simpleName, "/") { + return simpleName // Already in reverse-DNS format + } + return "io.github.stacklok/" + simpleName +} diff --git a/pkg/registry/official.go b/pkg/registry/official.go index e6704a4..810cf7d 100644 --- a/pkg/registry/official.go +++ b/pkg/registry/official.go @@ -9,11 +9,11 @@ import ( "strings" "time" - "github.com/google/uuid" upstream "github.com/modelcontextprotocol/registry/pkg/api/v0" "github.com/modelcontextprotocol/registry/pkg/model" "github.com/xeipuuv/gojsonschema" + "github.com/stacklok/toolhive-registry/pkg/registry/converters" "github.com/stacklok/toolhive-registry/pkg/types" ) @@ -72,41 +72,73 @@ func (or *OfficialRegistry) ValidateAgainstSchema() error { } // validateRegistry validates a registry object against the schema -func (*OfficialRegistry) validateRegistry(registry *ToolHiveRegistryType) error { - // Marshal registry to JSON +// This validates both the wrapper structure and each server entry +func (or *OfficialRegistry) validateRegistry(registry *ToolHiveRegistryType) error { + var allErrors []string + + // Step 1: Validate the wrapper structure against the ToolHive registry schema registryJSON, err := json.Marshal(registry) if err != nil { return fmt.Errorf("failed to marshal registry: %w", err) } - // Load schema from local file (fallback to remote if needed) - schemaPath := "schemas/registry.schema.json" - var schemaLoader gojsonschema.JSONLoader + // Load the wrapper schema from local file + wrapperSchemaPath := "schemas/registry.schema.json" + wrapperSchemaLoader := gojsonschema.NewReferenceLoader("file://" + wrapperSchemaPath) - // Try local schema first - if _, err := os.Stat(schemaPath); err == nil { - schemaLoader = gojsonschema.NewReferenceLoader("file://" + schemaPath) - } else { - // Fall back to remote schema - schemaLoader = gojsonschema.NewReferenceLoader( - "https://raw.githubusercontent.com/stacklok/toolhive-registry/main/schemas/registry.schema.json") + wrapperLoader := gojsonschema.NewBytesLoader(registryJSON) + wrapperResult, err := gojsonschema.Validate(wrapperSchemaLoader, wrapperLoader) + if err != nil { + return fmt.Errorf("wrapper schema validation failed: %w", err) } - // Create document loader from registry data - documentLoader := gojsonschema.NewBytesLoader(registryJSON) + if !wrapperResult.Valid() { + for _, desc := range wrapperResult.Errors() { + allErrors = append(allErrors, fmt.Sprintf("wrapper: %s", desc.String())) + } + } - // Perform validation - result, err := gojsonschema.Validate(schemaLoader, documentLoader) - if err != nil { - return fmt.Errorf("schema validation failed: %w", err) + // Step 2: Validate each server individually against the upstream MCP server schema + if err := or.validateServers(registry.Data.Servers, &allErrors); err != nil { + return err + } + + if len(allErrors) > 0 { + return fmt.Errorf("validation errors: %v", allErrors) } - if !result.Valid() { - var errorMessages []string - for _, desc := range result.Errors() { - errorMessages = append(errorMessages, desc.String()) + return nil +} + +// validateServers validates each server entry against the upstream MCP server schema +// This function can be used standalone to validate individual servers +func (*OfficialRegistry) validateServers(servers []upstream.ServerJSON, allErrors *[]string) error { + // Use the upstream schema URL directly from the registry package + // This ensures we're always validating against the same schema version + // that the code is built with, eliminating the need for manual schema syncing + serverSchemaLoader := gojsonschema.NewReferenceLoader(model.CurrentSchemaURL) + + for i, server := range servers { + // Marshal server to JSON + serverJSON, err := json.Marshal(server) + if err != nil { + return fmt.Errorf("failed to marshal server %d: %w", i, err) + } + + // Create document loader from server data + documentLoader := gojsonschema.NewBytesLoader(serverJSON) + + // Perform validation + result, err := gojsonschema.Validate(serverSchemaLoader, documentLoader) + if err != nil { + return fmt.Errorf("schema validation failed for server %d (%s): %w", i, server.Name, err) + } + + if !result.Valid() { + for _, desc := range result.Errors() { + *allErrors = append(*allErrors, fmt.Sprintf("data.servers.%d: %s", i, desc.String())) + } } - return fmt.Errorf("validation errors: %v", errorMessages) } return nil @@ -162,34 +194,32 @@ func (or *OfficialRegistry) build() *ToolHiveRegistryType { // transformEntry converts a ToolHive RegistryEntry to an official MCP ServerJSON func (or *OfficialRegistry) transformEntry(name string, entry *types.RegistryEntry) upstream.ServerJSON { - // Create the flattened server JSON with _meta extensions - serverJSON := upstream.ServerJSON{ - Name: or.convertNameToReverseDNS(name), - Description: entry.GetDescription(), - Status: or.convertStatus(entry.GetStatus()), - Repository: or.createRepository(entry), - Version: "1.0.0", // TODO: Default server version for now, fix this to use package/remote version - Meta: &upstream.ServerMeta{ - PublisherProvided: or.createXPublisherExtensions(entry), - // The registry extensions are not supposed to be set by us. - // They are generated by the registry system. - // We include them here so we can start using them in toolhive, - // and they are available when we support an official MCP registry. - Official: or.createRegistryExtensions(), - }, - } + var serverJSONPtr *upstream.ServerJSON + var err error - // Add packages for image-based servers + // Use the converters package for all conversion logic if entry.IsImage() { - serverJSON.Packages = or.createPackages(entry) - } - - // Add remotes for remote servers - if entry.IsRemote() { - serverJSON.Remotes = or.createRemotes(entry) + serverJSONPtr, err = converters.ImageMetadataToServerJSON(name, entry.ImageMetadata) + if err != nil || serverJSONPtr == nil { + // This shouldn't happen with valid data, but handle it gracefully + // Fall back to creating a minimal server entry + fallback := or.createFallbackServerJSON(name, entry) + return fallback + } + } else if entry.IsRemote() { + serverJSONPtr, err = converters.RemoteServerMetadataToServerJSON(name, entry.RemoteServerMetadata) + if err != nil || serverJSONPtr == nil { + // Fall back to creating a minimal server entry + fallback := or.createFallbackServerJSON(name, entry) + return fallback + } + } else { + // Neither image nor remote - create a minimal entry + fallback := or.createFallbackServerJSON(name, entry) + return fallback } - return serverJSON + return *serverJSONPtr } // createRepository creates repository information from entry @@ -202,16 +232,17 @@ func (*OfficialRegistry) createRepository(entry *types.RegistryEntry) model.Repo repositoryURL = entry.RemoteServerMetadata.RepositoryURL } + // If no repository URL is available, use toolhive-registry as fallback. + // This is necessary for schema validation - the upstream Repository field is a struct + // (not a pointer), so it can't be omitted with omitempty and would serialize as + // empty strings {"url": "", "source": ""}, which fails URI format validation. + // Using the toolhive-registry URL is reasonable since it's where these servers + // are registered and documented. if repositoryURL == "" { - // Use a toolhive-registry placeholder URL to satisfy validation when no repository is available for remote servers - repositoryURL = "https://github.com/stacklok/toolhive-registry" - if entry.IsRemote() { - return model.Repository{ - URL: repositoryURL, - Source: "github", - } + return model.Repository{ + URL: "https://github.com/stacklok/toolhive-registry", + Source: "github", } - return model.Repository{} } return model.Repository{ @@ -220,291 +251,6 @@ func (*OfficialRegistry) createRepository(entry *types.RegistryEntry) model.Repo } } -// createPackages creates Package entries for image-based servers -func (*OfficialRegistry) createPackages(entry *types.RegistryEntry) []model.Package { - if !entry.IsImage() || entry.Image == "" { - return nil - } - - // Convert environment variables - var envVars []model.KeyValueInput - for _, envVar := range entry.ImageMetadata.EnvVars { - envVars = append(envVars, model.KeyValueInput{ - Name: envVar.Name, - InputWithVariables: model.InputWithVariables{ - Input: model.Input{ - Description: envVar.Description, - IsRequired: envVar.Required, - IsSecret: envVar.Secret, - Default: envVar.Default, - }, - }, - }) - } - - // Extract registry and version information from the image reference - registryBaseURL, identifier, version, err := parseImageReference(entry.Image) - if err != nil { - // Continue with fallback values - registryBaseURL = "" - identifier = entry.Image - version = "" - } - - // Determine transport type - use entry's transport or default to stdio for containers - transportType := entry.GetTransport() - if transportType == "" { - transportType = "stdio" - } - - transport := model.Transport{ - Type: transportType, - } - - // Todo: Add URL field for non-stdio transports (required by schema) - - pkg := model.Package{ - RegistryType: model.RegistryTypeOCI, - RegistryBaseURL: registryBaseURL, - Identifier: identifier, - Version: version, - EnvironmentVariables: envVars, - Transport: transport, - } - - return []model.Package{pkg} -} - -// createRemotes creates Transport entries for remote servers -func (*OfficialRegistry) createRemotes(entry *types.RegistryEntry) []model.Transport { - if !entry.IsRemote() || entry.URL == "" { - return nil - } - - // Convert headers - var headers []model.KeyValueInput - for _, header := range entry.Headers { - headers = append(headers, model.KeyValueInput{ - Name: header.Name, - InputWithVariables: model.InputWithVariables{ - Input: model.Input{ - Description: header.Description, - IsRequired: header.Required, - IsSecret: header.Secret, - }, - }, - }) - } - - remote := model.Transport{ - Type: entry.GetTransport(), - URL: entry.URL, - Headers: headers, - } - - return []model.Transport{remote} -} - -// createRegistryExtensions creates registry-generated metadata -func (*OfficialRegistry) createRegistryExtensions() *upstream.RegistryExtensions { - now := time.Now().UTC() - return &upstream.RegistryExtensions{ - ID: uuid.NewString(), - PublishedAt: now, - UpdatedAt: now, - IsLatest: true, - } -} - -// createXPublisherExtensions creates x-publisher extensions with ToolHive-specific data -func (or *OfficialRegistry) createXPublisherExtensions(entry *types.RegistryEntry) map[string]interface{} { - // Get the key for the ToolHive extensions (image or URL) - var key string - if entry.IsImage() { - key = entry.Image - } else if entry.IsRemote() { - key = entry.URL - } else { - return map[string]interface{}{} // Empty if neither - } - - // Create ToolHive-specific extensions - toolhiveExtensions := or.createToolHiveExtensions(entry) - - return map[string]interface{}{ - "toolhive": map[string]interface{}{ - key: toolhiveExtensions, - }, - } -} - -// createToolHiveExtensions creates the ToolHive-specific extension data -func (or *OfficialRegistry) createToolHiveExtensions(entry *types.RegistryEntry) map[string]interface{} { - extensions := make(map[string]interface{}) - - // Always include transport type - extensions["transport"] = entry.GetTransport() - - // Add tools list - if tools := entry.GetTools(); len(tools) > 0 { - extensions["tools"] = tools - } - - // Add tier - if tier := entry.GetTier(); tier != "" { - extensions["tier"] = tier - } - - // Add common fields - if entry.IsImage() { - or.addImageSpecificExtensions(extensions, entry) - } else if entry.IsRemote() { - or.addRemoteSpecificExtensions(extensions, entry) - } - - // Add common optional fields - or.addCommonExtensions(extensions, entry) - - return extensions -} - -// addImageSpecificExtensions adds image-specific ToolHive extensions -func (*OfficialRegistry) addImageSpecificExtensions(extensions map[string]interface{}, entry *types.RegistryEntry) { - if entry.ImageMetadata == nil { - return - } - - // Add tags - if len(entry.ImageMetadata.Tags) > 0 { - extensions["tags"] = entry.ImageMetadata.Tags - } - - // Add permissions - if entry.Permissions != nil { - extensions["permissions"] = entry.Permissions - } - - // Add args (static container arguments) - if len(entry.Args) > 0 { - extensions["args"] = entry.Args - } - - // Add metadata (stars, pulls, etc.) - if entry.ImageMetadata.Metadata != nil { - extensions["metadata"] = entry.ImageMetadata.Metadata - } - - // Add provenance if present - if entry.Provenance != nil { - extensions["provenance"] = entry.Provenance - } -} - -// addRemoteSpecificExtensions adds remote-specific ToolHive extensions -func (*OfficialRegistry) addRemoteSpecificExtensions(extensions map[string]interface{}, entry *types.RegistryEntry) { - if entry.RemoteServerMetadata == nil { - return - } - - // Add tags - if len(entry.RemoteServerMetadata.Tags) > 0 { - extensions["tags"] = entry.RemoteServerMetadata.Tags - } - - // Add OAuth config - if entry.OAuthConfig != nil { - extensions["oauth_config"] = entry.OAuthConfig - } - - // Add metadata - if entry.RemoteServerMetadata.Metadata != nil { - extensions["metadata"] = entry.RemoteServerMetadata.Metadata - } -} - -// addCommonExtensions adds extensions common to both image and remote servers -func (*OfficialRegistry) addCommonExtensions(extensions map[string]interface{}, entry *types.RegistryEntry) { - // Add examples if present - if len(entry.Examples) > 0 { - extensions["examples"] = entry.Examples - } - - // Add license if present - if entry.License != "" { - extensions["license"] = entry.License - } -} - -// convertStatus converts ToolHive status to MCP model.Status -func (*OfficialRegistry) convertStatus(status string) model.Status { - switch status { - case types.StatusActive, "": - return model.StatusActive - case types.StatusDeprecated: - return model.StatusDeprecated - default: - return model.StatusActive // Default to active - } -} - -// parseImageReference parses a container image reference into basic components -// Returns error if registry has a port (not supported) -func parseImageReference(image string) (registryBaseURL, identifier, version string, err error) { - // Check for port in registry (not supported) - if strings.Contains(image, ":") && strings.Count(image, ":") > 1 { - // Multiple colons might indicate registry:port/image:tag - parts := strings.Split(image, "/") - if len(parts) > 0 && strings.Contains(parts[0], ":") { - // First part has colon, likely registry:port - return "", "", "", fmt.Errorf("registry with port not supported: %s", parts[0]) - } - } - - // Handle digest (@sha256:...) - if strings.Contains(image, "@") { - parts := strings.SplitN(image, "@", 2) - imageRef := parts[0] - digest := parts[1] - - reg, name := splitRegistryAndName(imageRef) - return reg, name, digest, nil - } - - // Handle tag (:tag) - if strings.Contains(image, ":") { - parts := strings.SplitN(image, ":", 2) - imageRef := parts[0] - tag := parts[1] - - reg, name := splitRegistryAndName(imageRef) - return reg, name, tag, nil - } - - // No tag or digest - default to latest - reg, name := splitRegistryAndName(image) - return reg, name, "latest", nil -} - -// splitRegistryAndName splits image into registry and name parts -func splitRegistryAndName(image string) (registryBaseURL, identifier string) { - // No slash = Docker Hub image - if !strings.Contains(image, "/") { - return "https://docker.io", image - } - - // Has slash - check if first part looks like registry - parts := strings.SplitN(image, "/", 2) - firstPart := parts[0] - - // If first part has dot, assume it's a registry hostname - if strings.Contains(firstPart, ".") { - return "https://" + firstPart, parts[1] - } - - // Otherwise assume Docker Hub with namespace - return "https://docker.io", image -} - // convertNameToReverseDNS converts simple server names to reverse-DNS format required by v1.0.0 schema func (*OfficialRegistry) convertNameToReverseDNS(name string) string { // If already in reverse-DNS format (contains '/'), return as-is @@ -512,6 +258,17 @@ func (*OfficialRegistry) convertNameToReverseDNS(name string) string { return name } - // Convert simple names to toolhive namespace format - return "io.stacklok.toolhive/" + name + // Convert simple names to GitHub-based namespace format + return "io.github.stacklok/" + name +} + +// createFallbackServerJSON creates a minimal ServerJSON when conversion fails +func (or *OfficialRegistry) createFallbackServerJSON(name string, entry *types.RegistryEntry) upstream.ServerJSON { + return upstream.ServerJSON{ + Schema: model.CurrentSchemaURL, + Name: or.convertNameToReverseDNS(name), + Description: entry.GetDescription(), + Version: "1.0.0", + Repository: or.createRepository(entry), + } } diff --git a/pkg/registry/schema_version_test.go b/pkg/registry/schema_version_test.go new file mode 100644 index 0000000..91946df --- /dev/null +++ b/pkg/registry/schema_version_test.go @@ -0,0 +1,82 @@ +package registry + +import ( + "encoding/json" + "os" + "regexp" + "testing" + + "github.com/modelcontextprotocol/registry/pkg/model" +) + +// TestSchemaVersionSync ensures that the schema reference in registry.schema.json +// matches the schema version from the Go package (model.CurrentSchemaVersion). +// This prevents schema drift when upgrading the registry package. +func TestSchemaVersionSync(t *testing.T) { + t.Parallel() + + // Read the schema file + schemaPath := "../../schemas/registry.schema.json" + schemaData, err := os.ReadFile(schemaPath) + if err != nil { + t.Fatalf("Failed to read schema file: %v", err) + } + + // Parse the schema JSON + var schema map[string]interface{} + if err := json.Unmarshal(schemaData, &schema); err != nil { + t.Fatalf("Failed to parse schema JSON: %v", err) + } + + // Navigate to the $ref field + servers, ok := schema["properties"].(map[string]interface{})["data"].(map[string]interface{})["properties"].(map[string]interface{})["servers"].(map[string]interface{}) + if !ok { + t.Fatal("Failed to navigate to servers field in schema") + } + + items, ok := servers["items"].(map[string]interface{}) + if !ok { + t.Fatal("Failed to get items field from servers") + } + + refURL, ok := items["$ref"].(string) + if !ok { + t.Fatal("Failed to get $ref URL from items") + } + + // Extract the date from the URL + // Expected format: https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json + re := regexp.MustCompile(`/schemas/([0-9]{4}-[0-9]{2}-[0-9]{2})/`) + matches := re.FindStringSubmatch(refURL) + if len(matches) != 2 { + t.Fatalf("Failed to extract date from schema URL: %s", refURL) + } + schemaDate := matches[1] + + // Compare with the Go package constant + expectedDate := model.CurrentSchemaVersion + if schemaDate != expectedDate { + t.Errorf("Schema version mismatch!\n"+ + " Schema file (%s): %s\n"+ + " Go package (model.CurrentSchemaVersion): %s\n\n"+ + "To fix: Update schemas/registry.schema.json line 49 to use date %s:\n"+ + " \"$ref\": \"https://static.modelcontextprotocol.io/schemas/%s/server.schema.json\"", + schemaPath, schemaDate, expectedDate, expectedDate, expectedDate) + } + + // Also check the _schema_version metadata for documentation + schemaVersionMeta, ok := schema["_schema_version"].(map[string]interface{}) + if !ok { + t.Log("Warning: _schema_version metadata not found (non-critical)") + return + } + + metaDate, ok := schemaVersionMeta["schema_date"].(string) + if ok && metaDate != expectedDate { + t.Errorf("Schema version metadata is out of sync!\n"+ + " _schema_version.schema_date: %s\n"+ + " Expected: %s\n\n"+ + "Update the _schema_version.schema_date field in schemas/registry.schema.json", + metaDate, expectedDate) + } +} diff --git a/registry/dolt/spec.yaml b/registry/dolt/spec.yaml index 22abffd..2639823 100644 --- a/registry/dolt/spec.yaml +++ b/registry/dolt/spec.yaml @@ -1,5 +1,6 @@ # Docker/OCI image reference image: docker.io/dolthub/dolt-mcp:0.2.2 +target_port: 8080 # One-line description description: Git-like version control for SQL databases with branching, merging, and data versioning # Communication protocol diff --git a/registry/fetch/spec.yaml b/registry/fetch/spec.yaml index c320acb..ea4f370 100644 --- a/registry/fetch/spec.yaml +++ b/registry/fetch/spec.yaml @@ -28,6 +28,7 @@ tags: - curl - modelcontextprotocol image: ghcr.io/stackloklabs/gofetch/server:1.0.1 +target_port: 8080 permissions: network: outbound: diff --git a/registry/genai-toolbox/spec.yaml b/registry/genai-toolbox/spec.yaml index 9997ba7..45eef6f 100644 --- a/registry/genai-toolbox/spec.yaml +++ b/registry/genai-toolbox/spec.yaml @@ -18,6 +18,7 @@ metadata: pulls: 2408 last_updated: "2025-10-14T02:29:24Z" repository_url: https://github.com/googleapis/genai-toolbox +target_port: 5000 tags: - database - sql diff --git a/registry/k8s/spec.yaml b/registry/k8s/spec.yaml index 8a132b7..c6bde37 100644 --- a/registry/k8s/spec.yaml +++ b/registry/k8s/spec.yaml @@ -31,6 +31,7 @@ tags: - get - list image: ghcr.io/stackloklabs/mkp/server:0.2.4 +target_port: 8080 permissions: network: outbound: diff --git a/registry/mcp-optimizer/spec.yaml b/registry/mcp-optimizer/spec.yaml index ffb5e39..3c3c113 100644 --- a/registry/mcp-optimizer/spec.yaml +++ b/registry/mcp-optimizer/spec.yaml @@ -9,11 +9,14 @@ tools: - find_tool - call_tool - list_tools + - search_registry + - install_server metadata: stars: 0 pulls: 0 last_updated: "2025-10-13T02:32:37Z" repository_url: https://github.com/StacklokLabs/mcp-optimizer +target_port: 9900 tags: - mcp - proxy diff --git a/registry/oci-registry/spec.yaml b/registry/oci-registry/spec.yaml index b18de67..d94336a 100644 --- a/registry/oci-registry/spec.yaml +++ b/registry/oci-registry/spec.yaml @@ -19,6 +19,7 @@ metadata: pulls: 8029 last_updated: "2025-10-21T02:31:33Z" repository_url: https://github.com/StacklokLabs/ocireg-mcp +target_port: 8080 tags: - oci - registry diff --git a/registry/osv/spec.yaml b/registry/osv/spec.yaml index 6eb45fd..d9810a7 100644 --- a/registry/osv/spec.yaml +++ b/registry/osv/spec.yaml @@ -30,6 +30,7 @@ tags: - security-scanning - vulnerability-detection image: ghcr.io/stackloklabs/osv-mcp/server:0.0.7 +target_port: 8080 permissions: network: outbound: diff --git a/registry/plotting/spec.yaml b/registry/plotting/spec.yaml index 8107645..9b6f28e 100644 --- a/registry/plotting/spec.yaml +++ b/registry/plotting/spec.yaml @@ -4,6 +4,8 @@ # Original source: https://github.com/stacklok/toolhive # Import timestamp: 2025-08-14T07:27:00Z # --- +# WARNING: Tool list fetch failed on 2025-10-28 +# Manual verification may be required name: plotting description: Provides plotting capabilities for visualizing data in various formats. tier: Community @@ -27,6 +29,7 @@ tags: - cartopy - maps image: ghcr.io/stackloklabs/plotting-mcp:v0.0.2 +target_port: 9090 permissions: network: outbound: {} diff --git a/registry/semgrep/spec.yaml b/registry/semgrep/spec.yaml index 0f7b5da..ec3ad33 100644 --- a/registry/semgrep/spec.yaml +++ b/registry/semgrep/spec.yaml @@ -24,7 +24,8 @@ metadata: stars: 591 pulls: 12180 last_updated: "2025-10-20T02:33:41Z" -repository_url: https://github.com/semgrep/mcp +repository_url: https://github.com/semgrep/semgrep +target_port: 8000 tags: - security - static-analysis @@ -36,7 +37,7 @@ tags: - semgrep - ast - code-analysis -image: ghcr.io/semgrep/mcp:0.9.0 +image: semgrep/semgrep:latest permissions: network: outbound: diff --git a/registry/sqlite/spec.yaml b/registry/sqlite/spec.yaml index 2a0ae60..46120c1 100644 --- a/registry/sqlite/spec.yaml +++ b/registry/sqlite/spec.yaml @@ -4,6 +4,8 @@ # Original source: https://github.com/stacklok/toolhive # Import timestamp: 2025-08-14T07:27:00Z # --- +# WARNING: Tool list fetch failed on 2025-10-23 +# Manual verification may be required name: sqlite description: Provides tools and resources for querying SQLite databases. tier: Community @@ -19,6 +21,7 @@ metadata: pulls: 4212 last_updated: "2025-10-15T02:30:48Z" repository_url: https://github.com/StacklokLabs/sqlite-mcp +target_port: 8080 tags: - data - database diff --git a/renovate.json b/renovate.json index 3837c18..46ac316 100644 --- a/renovate.json +++ b/renovate.json @@ -34,21 +34,10 @@ "automerge": true }, { - "description": "MCP Registry dependency updates with automated schema sync", + "description": "MCP Registry dependency updates", "matchPackageNames": [ "github.com/modelcontextprotocol/registry" ], - "postUpgradeTasks": { - "commands": [ - "chmod +x scripts/sync-schema-version.sh", - "./scripts/sync-schema-version.sh" - ], - "fileFilters": [ - "schemas/registry.schema.json" - ], - "executionMode": "update" - }, - "prBodyTemplate": "## šŸ”„ MCP Registry Dependency Update\n\nThis PR updates the MCP registry dependency and automatically syncs the schema reference.\n\n### Changes\n- **Dependency**: `github.com/modelcontextprotocol/registry` `{{currentVersion}}` → `{{newVersion}}`\n- **Schema**: Automatically synced schema reference commit SHA to match the updated dependency\n\n### āœ… Automated Tasks Completed\n- āœ… **Go Modules**: Updated and tidied\n- āœ… **Schema Sync**: Schema reference updated to match dependency version\n\n### šŸš€ Next Steps\nThis PR will be validated by the CI workflow:\n- **Registry Validation**: All registry entries will be validated against the new schema\n- **Registry Build**: Both ToolHive and Official MCP formats will be built and tested\n- **Tool Updates**: Any affected registry spec files will be automatically updated\n\nIf validation passes, this PR can be merged immediately. If validation fails, the PR will be converted to draft status with detailed error information.\n\n---\n\nšŸ¤– This PR was created automatically by Renovate with enhanced MCP registry support.\n\n{{{links}}}\n\n{{{controls}}}", "commitMessageTopic": "MCP registry dependency", "semanticCommitType": "chore", "semanticCommitScope": "deps" diff --git a/schemas/registry.schema.json b/schemas/registry.schema.json index 2db4366..ffdcfeb 100644 --- a/schemas/registry.schema.json +++ b/schemas/registry.schema.json @@ -46,7 +46,7 @@ "type": "array", "description": "Array of MCP servers using the official MCP server schema", "items": { - "$ref": "https://raw.githubusercontent.com/modelcontextprotocol/registry/f975e68cf25c776160d4e837919884ca026027d6/docs/reference/server-json/server.schema.json#/$defs/ServerDetail" + "$ref": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json" } }, "groups": { @@ -61,9 +61,9 @@ } }, "_schema_version": { - "mcp_registry_version": "v1.0.0", - "mcp_registry_commit": "f975e68cf25c776160d4e837919884ca026027d6", - "updated_at": "2025-09-11T14:07:36Z", - "updated_by": "sync-schema-version.sh" + "mcp_registry_version": "v1.3.5", + "schema_date": "2025-10-17", + "updated_at": "2025-10-22T12:35:00Z", + "updated_by": "manual update to use static.modelcontextprotocol.io schema URL" } } diff --git a/scripts/sync-schema-version.sh b/scripts/sync-schema-version.sh deleted file mode 100755 index 7622bcc..0000000 --- a/scripts/sync-schema-version.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# sync-schema-version.sh -# Syncs the schema reference commit SHA with the Go module version -# Ensures schema validation uses the exact same version as the Go code - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -SCHEMA_FILE="$PROJECT_ROOT/schemas/registry.schema.json" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -log() { - echo -e "${BLUE}[sync-schema]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[sync-schema]${NC} $1" -} - -error() { - echo -e "${RED}[sync-schema]${NC} $1" >&2 -} - -success() { - echo -e "${GREEN}[sync-schema]${NC} $1" -} - -# Check if jq is available -check_dependencies() { - if ! command -v jq >/dev/null 2>&1; then - error "jq is required but not installed. Please install it:" - error " macOS: brew install jq" - error " Ubuntu: apt-get install jq" - exit 1 - fi -} - -# Function to extract commit SHA from go.mod version -get_current_commit_sha() { - local version - version=$(grep "github.com/modelcontextprotocol/registry" "$PROJECT_ROOT/go.mod" | awk '{print $2}') - - if [[ "$version" =~ v[0-9]+\.[0-9]+\.[0-9]+-[0-9]+-([a-f0-9]+)$ ]]; then - # Extract SHA from pseudo-version (v0.0.0-20250903150202-6ea3828e3ce6) - echo "${BASH_REMATCH[1]}" - elif [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - # For tagged versions, we need to resolve to commit SHA - warn "Tagged version detected: $version" >&2 - warn "Will attempt to resolve commit SHA from GitHub..." >&2 - - # Try to get commit SHA for the tag - local sha - sha=$(curl -s "https://api.github.com/repos/modelcontextprotocol/registry/git/refs/tags/$version" | \ - jq -r '.object.sha // empty' 2>/dev/null) - - if [[ -n "$sha" ]]; then - echo "$sha" - else - error "Failed to resolve commit SHA for tagged version: $version" - return 1 - fi - else - error "Unable to parse version format: $version" - return 1 - fi -} - -# Function to get current SHA from schema -get_schema_commit_sha() { - if [[ ! -f "$SCHEMA_FILE" ]]; then - error "Schema file not found: $SCHEMA_FILE" - return 1 - fi - - # Extract SHA from the GitHub raw URL using jq - local ref_url - ref_url=$(jq -r '.properties.data.properties.servers.items["$ref"] // empty' "$SCHEMA_FILE") - - if [[ -n "$ref_url" ]]; then - # Extract SHA from URL like: https://raw.githubusercontent.com/.../registry/6ea3828e3ce62cfd9815376cd6825453da011fa1/docs/... - echo "$ref_url" | sed 's|.*/registry/\([^/]*\)/.*|\1|' - else - error "No schema reference URL found" - return 1 - fi -} - -# Function to get Go module version for metadata -get_go_module_version() { - grep "github.com/modelcontextprotocol/registry" "$PROJECT_ROOT/go.mod" | awk '{print $2}' -} - -# Function to update schema with new commit SHA using jq -update_schema_reference() { - local new_sha="$1" - local old_sha="$2" - local go_version="$3" - - log "Updating schema reference from $old_sha to $new_sha" - - - local timestamp - timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Update both the $ref URL and add/update metadata using jq - jq --arg new_sha "$new_sha" \ - --arg old_sha "$old_sha" \ - --arg go_version "$go_version" \ - --arg timestamp "$timestamp" \ - ' - # Update the $ref URL - .properties.data.properties.servers.items["$ref"] |= - gsub("/registry/[^/]+/"; - "/registry/" + $new_sha + "/") | - - # Add or update _schema_version metadata - ._schema_version = { - "mcp_registry_version": $go_version, - "mcp_registry_commit": $new_sha, - "updated_at": $timestamp, - "updated_by": "sync-schema-version.sh" - } - ' "$SCHEMA_FILE" > "$SCHEMA_FILE.tmp" - - # Replace original with updated version - mv "$SCHEMA_FILE.tmp" "$SCHEMA_FILE" - - success "Schema reference updated to commit $new_sha" -} - - -# Function to show what changed -show_changes() { - local new_sha="$1" - local old_sha="$2" - - log "Changes summary:" - log " Schema reference updated:" - log " From: https://raw.githubusercontent.com/.../registry/$old_sha/docs/..." - log " To: https://raw.githubusercontent.com/.../registry/$new_sha/docs/..." - log "" - - if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then - log "Git diff:" - git --no-pager diff "$SCHEMA_FILE" || true - fi -} - -# Main function -main() { - log "Starting schema version sync..." - - # Check dependencies first - check_dependencies - - cd "$PROJECT_ROOT" - - # Get current commit SHA from go.mod - log "Extracting commit SHA from go.mod..." - local current_sha - if ! current_sha=$(get_current_commit_sha); then - error "Failed to extract commit SHA from go.mod" - exit 1 - fi - - # Get Go module version for metadata - local go_version - go_version=$(get_go_module_version) - - log "Current Go module version: $go_version" - log "Current Go module commit SHA: $current_sha" - - # Get current SHA from schema - log "Extracting commit SHA from schema..." - local schema_sha - if ! schema_sha=$(get_schema_commit_sha); then - error "Failed to extract commit SHA from schema" - exit 1 - fi - - log "Current schema commit SHA: $schema_sha" - - # Compare and update if different (handle short vs long SHA) - if [[ "$schema_sha" == "$current_sha"* ]] || [[ "$current_sha" == "$schema_sha"* ]]; then - success "Schema reference is already in sync! (SHA: $current_sha)" - exit 0 - fi - - warn "Schema reference is out of sync!" - warn " Go module SHA: $current_sha" - warn " Schema SHA: $schema_sha" - - # Update schema reference - if ! update_schema_reference "$current_sha" "$schema_sha" "$go_version"; then - error "Failed to update schema reference" - exit 1 - fi - - # Show what changed - show_changes "$current_sha" "$schema_sha" - - success "Schema sync completed successfully!" - log "" - log "Next steps:" - log " 1. Review the changes above" - log " 2. Test the registry build: task build:registry" - log " 3. Commit the changes if everything looks good" -} - -# Run main function -main "$@" \ No newline at end of file