Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions pkg/registry/converters/registry_converters.go
Original file line number Diff line number Diff line change
@@ -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
}
196 changes: 196 additions & 0 deletions pkg/registry/converters/registry_converters_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 19 additions & 0 deletions pkg/registry/types/upstream_registry.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading