diff --git a/pkg/registry/converters/registry_converters.go b/pkg/registry/converters/registry_converters.go new file mode 100644 index 000000000..5fc7784e5 --- /dev/null +++ b/pkg/registry/converters/registry_converters.go @@ -0,0 +1,55 @@ +package converters + +import ( + "fmt" + "time" + + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + + "github.com/stacklok/toolhive/pkg/registry/types" +) + +// NewServerRegistryFromUpstream creates a ServerRegistry from upstream ServerJSON array. +// This is used when ingesting data from upstream MCP Registry API endpoints. +func NewServerRegistryFromUpstream(servers []upstreamv0.ServerJSON) *types.ServerRegistry { + return &types.ServerRegistry{ + Version: "1.0.0", + LastUpdated: time.Now().Format(time.RFC3339), + Servers: servers, + } +} + +// NewServerRegistryFromToolhive creates a ServerRegistry from ToolHive Registry. +// This converts ToolHive format to upstream ServerJSON using the converters package. +// Used when ingesting data from ToolHive-format sources (Git, File, API). +func NewServerRegistryFromToolhive(toolhiveReg *types.Registry) (*types.ServerRegistry, error) { + if toolhiveReg == nil { + return nil, fmt.Errorf("toolhive registry cannot be nil") + } + + servers := make([]upstreamv0.ServerJSON, 0, len(toolhiveReg.Servers)+len(toolhiveReg.RemoteServers)) + + // Convert container servers using converters package + for name, imgMeta := range toolhiveReg.Servers { + serverJSON, err := ImageMetadataToServerJSON(name, imgMeta) + if err != nil { + return nil, fmt.Errorf("failed to convert server %s: %w", name, err) + } + servers = append(servers, *serverJSON) + } + + // Convert remote servers using converters package + for name, remoteMeta := range toolhiveReg.RemoteServers { + serverJSON, err := RemoteServerMetadataToServerJSON(name, remoteMeta) + if err != nil { + return nil, fmt.Errorf("failed to convert remote server %s: %w", name, err) + } + servers = append(servers, *serverJSON) + } + + return &types.ServerRegistry{ + Version: toolhiveReg.Version, + LastUpdated: toolhiveReg.LastUpdated, + Servers: servers, + }, nil +} diff --git a/pkg/registry/converters/registry_converters_test.go b/pkg/registry/converters/registry_converters_test.go new file mode 100644 index 000000000..2de75409e --- /dev/null +++ b/pkg/registry/converters/registry_converters_test.go @@ -0,0 +1,196 @@ +package converters + +import ( + "testing" + "time" + + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/pkg/registry/types" +) + +func TestNewServerRegistryFromToolhive(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + toolhiveReg *types.Registry + expectError bool + validate func(*testing.T, *types.ServerRegistry) + }{ + { + name: "successful conversion with container servers", + toolhiveReg: &types.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: map[string]*types.ImageMetadata{ + "test-server": { + BaseServerMetadata: types.BaseServerMetadata{ + Name: "test-server", + Description: "A test server", + Tier: "Community", + Status: "Active", + Transport: "stdio", + Tools: []string{"test_tool"}, + }, + Image: "test/image:latest", + }, + }, + RemoteServers: make(map[string]*types.RemoteServerMetadata), + }, + expectError: false, + validate: func(t *testing.T, sr *types.ServerRegistry) { + t.Helper() + assert.Equal(t, "1.0.0", sr.Version) + assert.Equal(t, "2024-01-01T00:00:00Z", sr.LastUpdated) + assert.Len(t, sr.Servers, 1) + assert.Contains(t, sr.Servers[0].Name, "test-server") + assert.Equal(t, "A test server", sr.Servers[0].Description) + }, + }, + { + name: "successful conversion with remote servers", + toolhiveReg: &types.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: make(map[string]*types.ImageMetadata), + RemoteServers: map[string]*types.RemoteServerMetadata{ + "remote-server": { + BaseServerMetadata: types.BaseServerMetadata{ + Name: "remote-server", + Description: "A remote server", + Tier: "Community", + Status: "Active", + Transport: "sse", + Tools: []string{"remote_tool"}, + }, + URL: "https://example.com", + }, + }, + }, + expectError: false, + validate: func(t *testing.T, sr *types.ServerRegistry) { + t.Helper() + assert.Len(t, sr.Servers, 1) + assert.Contains(t, sr.Servers[0].Name, "remote-server") + }, + }, + { + name: "empty registry", + toolhiveReg: &types.Registry{ + Version: "1.0.0", + LastUpdated: "2024-01-01T00:00:00Z", + Servers: make(map[string]*types.ImageMetadata), + RemoteServers: make(map[string]*types.RemoteServerMetadata), + }, + expectError: false, + validate: func(t *testing.T, sr *types.ServerRegistry) { + t.Helper() + assert.Empty(t, sr.Servers) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := NewServerRegistryFromToolhive(tt.toolhiveReg) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + if tt.validate != nil { + tt.validate(t, result) + } + } + }) + } +} + +func TestNewServerRegistryFromUpstream(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + servers []upstreamv0.ServerJSON + validate func(*testing.T, *types.ServerRegistry) + }{ + { + name: "create from upstream servers", + servers: []upstreamv0.ServerJSON{ + { + Schema: "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + Name: "io.test/server1", + Description: "Test server 1", + Version: "1.0.0", + Packages: []model.Package{ + { + RegistryType: "oci", + Identifier: "test/image:latest", + Transport: model.Transport{Type: "stdio"}, + }, + }, + }, + }, + validate: func(t *testing.T, sr *types.ServerRegistry) { + t.Helper() + assert.Equal(t, "1.0.0", sr.Version) + assert.NotEmpty(t, sr.LastUpdated) + assert.Len(t, sr.Servers, 1) + assert.Equal(t, "io.test/server1", sr.Servers[0].Name) + }, + }, + { + name: "create from empty slice", + servers: []upstreamv0.ServerJSON{}, + validate: func(t *testing.T, sr *types.ServerRegistry) { + t.Helper() + assert.Empty(t, sr.Servers) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := NewServerRegistryFromUpstream(tt.servers) + + assert.NotNil(t, result) + if tt.validate != nil { + tt.validate(t, result) + } + }) + } +} + +func TestNewServerRegistryFromUpstream_DefaultValues(t *testing.T) { + t.Parallel() + + servers := []upstreamv0.ServerJSON{ + { + Schema: "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + Name: "io.test/server1", + Description: "Test server", + Version: "1.0.0", + }, + } + + result := NewServerRegistryFromUpstream(servers) + + // Verify defaults + assert.Equal(t, "1.0.0", result.Version) + assert.NotEmpty(t, result.LastUpdated) + + // Verify timestamp is recent (within last minute) + parsedTime, err := time.Parse(time.RFC3339, result.LastUpdated) + require.NoError(t, err) + assert.WithinDuration(t, time.Now(), parsedTime, time.Minute) +} diff --git a/pkg/registry/types/upstream_registry.go b/pkg/registry/types/upstream_registry.go new file mode 100644 index 000000000..9d87efb83 --- /dev/null +++ b/pkg/registry/types/upstream_registry.go @@ -0,0 +1,19 @@ +package types + +import ( + upstreamv0 "github.com/modelcontextprotocol/registry/pkg/api/v0" +) + +// ServerRegistry is the unified internal registry format. +// It stores servers in upstream ServerJSON format while maintaining +// ToolHive-compatible metadata fields for backward compatibility. +type ServerRegistry struct { + // Version is the schema version (ToolHive compatibility) + Version string `json:"version"` + + // LastUpdated is the timestamp when registry was last updated (ToolHive compatibility) + LastUpdated string `json:"last_updated"` + + // Servers contains the server definitions in upstream MCP format + Servers []upstreamv0.ServerJSON `json:"servers"` +}