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
10 changes: 10 additions & 0 deletions cmd/thv-registry-api/api/v1/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ func (*realisticRegistryProvider) GetSource() string {
return "test:realistic-registry-data"
}

// GetRegistryName implements RegistryDataProvider.GetRegistryName
func (*realisticRegistryProvider) GetRegistryName() string {
return "test-registry"
}

func TestHealthRouter(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
Expand Down Expand Up @@ -607,6 +612,11 @@ func (*fileBasedRegistryProvider) GetSource() string {
return "embedded:pkg/registry/data/registry.json"
}

// GetRegistryName implements RegistryDataProvider.GetRegistryName
func (*fileBasedRegistryProvider) GetRegistryName() string {
return "embedded-registry"
}

// TestRoutesWithRealData tests all routes using the embedded registry.json data
// This provides integration-style testing with realistic MCP server configurations
func TestRoutesWithRealData(t *testing.T) {
Expand Down
118 changes: 90 additions & 28 deletions cmd/thv-registry-api/app/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the registry API server",
Long: `Start the registry API server to serve MCP registry data.
The server reads registry data from ConfigMaps and provides REST endpoints for clients.`,
The server can read registry data from either:
- ConfigMaps using --from-configmap flag (requires Kubernetes API access)
- Local files using --from-file flag (for mounted ConfigMaps)

Both options require --registry-name to specify the registry identifier.
One of --from-configmap or --from-file must be specified.`,
RunE: runServe,
}

Expand All @@ -41,15 +46,25 @@ const (

func init() {
serveCmd.Flags().String("address", ":8080", "Address to listen on")
serveCmd.Flags().String("configmap", "", "ConfigMap name containing registry data")
serveCmd.Flags().String("from-configmap", "", "ConfigMap name containing registry data (mutually exclusive with --from-file)")
serveCmd.Flags().String("from-file", "", "File path to registry.json (mutually exclusive with --from-configmap)")
serveCmd.Flags().String("registry-name", "", "Registry name identifier (required)")

err := viper.BindPFlag("address", serveCmd.Flags().Lookup("address"))
if err != nil {
logger.Fatalf("Failed to bind address flag: %v", err)
}
err = viper.BindPFlag("configmap", serveCmd.Flags().Lookup("configmap"))
err = viper.BindPFlag("from-configmap", serveCmd.Flags().Lookup("from-configmap"))
if err != nil {
logger.Fatalf("Failed to bind from-configmap flag: %v", err)
}
err = viper.BindPFlag("from-file", serveCmd.Flags().Lookup("from-file"))
if err != nil {
logger.Fatalf("Failed to bind from-file flag: %v", err)
}
err = viper.BindPFlag("registry-name", serveCmd.Flags().Lookup("registry-name"))
if err != nil {
logger.Fatalf("Failed to bind configmap flag: %v", err)
logger.Fatalf("Failed to bind registry-name flag: %v", err)
}
}

Expand All @@ -68,48 +83,95 @@ func getKubernetesConfig() (*rest.Config, error) {
return kubeConfig.ClientConfig()
}

func runServe(_ *cobra.Command, _ []string) error {
ctx := context.Background()
// buildProviderConfig creates provider configuration based on command-line flags
func buildProviderConfig() (*service.RegistryProviderConfig, error) {
configMapName := viper.GetString("from-configmap")
filePath := viper.GetString("from-file")
registryName := viper.GetString("registry-name")

// Get configuration
address := viper.GetString("address")
configMapName := viper.GetString("configmap")
// Validate mutual exclusivity
if configMapName != "" && filePath != "" {
return nil, fmt.Errorf("--from-configmap and --from-file flags are mutually exclusive")
}

// Require one of the flags
if configMapName == "" && filePath == "" {
return nil, fmt.Errorf("either --from-configmap or --from-file flag is required")
}

if configMapName == "" {
return fmt.Errorf("configmap flag is required")
// Require registry name
if registryName == "" {
return nil, fmt.Errorf("--registry-name flag is required")
}

namespace := thvk8scli.GetCurrentNamespace()
if configMapName != "" {
config, err := getKubernetesConfig()
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes config: %w", err)
}

clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}

return &service.RegistryProviderConfig{
Type: service.RegistryProviderTypeConfigMap,
ConfigMap: &service.ConfigMapProviderConfig{
Name: configMapName,
Namespace: thvk8scli.GetCurrentNamespace(),
Clientset: clientset,
RegistryName: registryName,
},
}, nil
}

return &service.RegistryProviderConfig{
Type: service.RegistryProviderTypeFile,
File: &service.FileProviderConfig{
FilePath: filePath,
RegistryName: registryName,
},
}, nil
}

func runServe(_ *cobra.Command, _ []string) error {
ctx := context.Background()
address := viper.GetString("address")

logger.Infof("Starting registry API server on %s", address)
logger.Infof("ConfigMap: %s, Namespace: %s", configMapName, namespace)

// Create Kubernetes client and providers
var registryProvider service.RegistryDataProvider
var deploymentProvider service.DeploymentProvider
providerConfig, err := buildProviderConfig()
if err != nil {
return fmt.Errorf("failed to build provider configuration: %w", err)
}

// Get Kubernetes config
config, err := getKubernetesConfig()
if err := providerConfig.Validate(); err != nil {
return fmt.Errorf("invalid provider configuration: %w", err)
}

factory := service.NewRegistryProviderFactory()
registryProvider, err := factory.CreateProvider(providerConfig)
if err != nil {
return fmt.Errorf("failed to create kubernetes config: %w", err)
return fmt.Errorf("failed to create registry provider: %w", err)
}

// Create Kubernetes client
clientset, err := kubernetes.NewForConfig(config)
logger.Infof("Created registry data provider: %s", registryProvider.GetSource())

var deploymentProvider service.DeploymentProvider
config, err := getKubernetesConfig()
if err != nil {
return fmt.Errorf("failed to create kubernetes client: %w", err)
return fmt.Errorf("failed to create kubernetes config for deployment provider: %w", err)
}

// Create the Kubernetes-based registry data provider
registryProvider = service.NewK8sRegistryDataProvider(clientset, configMapName, namespace)
logger.Infof("Created Kubernetes registry data provider for ConfigMap %s/%s", namespace, configMapName)
// Use registry name from provider
registryName := registryProvider.GetRegistryName()

// Create the Kubernetes-based deployment provider
deploymentProvider, err = service.NewK8sDeploymentProvider(config, configMapName)
deploymentProvider, err = service.NewK8sDeploymentProvider(config, registryName)
if err != nil {
return fmt.Errorf("failed to create kubernetes deployment provider: %w", err)
}
logger.Infof("Created Kubernetes deployment provider for registry: %s", configMapName)
logger.Infof("Created Kubernetes deployment provider for registry: %s", registryName)

// Create the registry service
svc, err := service.NewService(ctx, registryProvider, deploymentProvider)
Expand Down
78 changes: 78 additions & 0 deletions cmd/thv-registry-api/internal/service/file_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Package service provides the business logic for the MCP registry API
package service

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/stacklok/toolhive/pkg/registry"
)

// FileRegistryDataProvider implements RegistryDataProvider using local file system.
// This implementation reads registry data from a mounted file instead of calling the Kubernetes API.
// It is designed to work with ConfigMaps mounted as volumes in Kubernetes deployments.
type FileRegistryDataProvider struct {
filePath string
registryName string
}

// NewFileRegistryDataProvider creates a new file-based registry data provider.
// The filePath parameter should point to the registry.json file, typically mounted from a ConfigMap.
// The registryName parameter specifies the registry identifier for business logic purposes.
func NewFileRegistryDataProvider(filePath, registryName string) *FileRegistryDataProvider {
return &FileRegistryDataProvider{
filePath: filePath,
registryName: registryName,
}
}

// GetRegistryData implements RegistryDataProvider.GetRegistryData.
// It reads the registry.json file from the local filesystem and parses it into a Registry struct.
func (p *FileRegistryDataProvider) GetRegistryData(_ context.Context) (*registry.Registry, error) {
if p.filePath == "" {
return nil, fmt.Errorf("file path not configured")
}

// Check if the file exists and is readable
if _, err := os.Stat(p.filePath); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("registry file not found at %s: %w", p.filePath, err)
}
return nil, fmt.Errorf("cannot access registry file at %s: %w", p.filePath, err)
}

// Read the file contents
data, err := os.ReadFile(p.filePath)
if err != nil {
return nil, fmt.Errorf("failed to read registry file at %s: %w", p.filePath, err)
}

// Parse the JSON data
var reg registry.Registry
if err := json.Unmarshal(data, &reg); err != nil {
return nil, fmt.Errorf("failed to parse registry data from file %s: %w", p.filePath, err)
}

return &reg, nil
}

// GetSource implements RegistryDataProvider.GetSource.
// It returns a descriptive string indicating the file source.
func (p *FileRegistryDataProvider) GetSource() string {
if p.filePath == "" {
return "file:<not-configured>"
}

// Clean the path for consistent display
cleanPath := filepath.Clean(p.filePath)
return fmt.Sprintf("file:%s", cleanPath)
}

// GetRegistryName implements RegistryDataProvider.GetRegistryName.
// It returns the injected registry name identifier.
func (p *FileRegistryDataProvider) GetRegistryName() string {
return p.registryName
}
Loading
Loading