Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a412662
- SourceHandler interface
dmartinol Sep 9, 2025
2f6a710
- SourceHandler and FormatConverter types
dmartinol Sep 9, 2025
0fad910
using Registry schema
dmartinol Sep 9, 2025
19feb63
Refactor ConfigMap handling to use a constant key for registry data
dmartinol Sep 10, 2025
5207074
Refactor storage manager tests to use constant for ConfigMap key
dmartinol Sep 10, 2025
8ed2dde
Refactor registry handling and improve test coverage
dmartinol Sep 10, 2025
ac7a7da
Refactor storage manager to use constant for component label
dmartinol Sep 10, 2025
6b4819d
Refactor MCPRegistry controller and source handling
dmartinol Sep 10, 2025
acf47b0
Remove FormatConverter and associated tests from the project
dmartinol Sep 11, 2025
bf99349
bumped chart version
dmartinol Sep 11, 2025
69021cc
Manual sync logic
dmartinol Sep 11, 2025
5506364
Moved sync logic in sync package
dmartinol Sep 11, 2025
217e3db
Fixed rebase errors
dmartinol Sep 12, 2025
f8c15cc
review comments
dmartinol Sep 15, 2025
0b606f1
Add MCPRegistry filtering system with enhanced reason generation
dmartinol Sep 15, 2025
46115d1
Fix missing newline at end of file in filtering package documentation
dmartinol Sep 15, 2025
7958fcd
- Introduce filtering capabilities during registry data fetch and pro…
dmartinol Sep 15, 2025
8b72e25
rebase issues
dmartinol Sep 16, 2025
32d918f
Enhance unit tests for DefaultSyncManager to include server count val…
dmartinol Sep 16, 2025
7591a28
more examples, reviewed log level
dmartinol Sep 16, 2025
59935da
Merge branch 'stacklok:main' into registry_filter
dmartinol Sep 16, 2025
c31607b
catching filepath.Match error
dmartinol Sep 16, 2025
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
65 changes: 65 additions & 0 deletions cmd/thv-operator/pkg/filtering/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Package filtering provides server filtering capabilities for MCPRegistry resources.
//
// This package implements a comprehensive filtering system that allows MCPRegistry
// controllers to selectively include or exclude servers based on name patterns
// and tags. The filtering system supports both include and exclude rules with
// exclude taking precedence over include.
//
// # Architecture
//
// The filtering system consists of three main components:
//
// - NameFilter: Handles server name filtering using glob patterns
// - TagFilter: Handles tag-based filtering using exact string matching
// - FilterService: Coordinates both name and tag filtering
//
// # Name Filtering
//
// Name filtering uses Go's filepath.Match for glob pattern matching, supporting
// wildcards like '*', '?', and character classes '[...]'. Examples:
//
// - "postgres-*" matches "postgres-server", "postgres-client"
// - "db?" matches "db1", "db2" but not "database"
// - "server[1-3]" matches "server1", "server2", "server3"
//
// # Tag Filtering
//
// Tag filtering uses exact string matching against server tags. A server is
// included if any of its tags match any include tag, and excluded if any of
// its tags match any exclude tag.
//
// # Filtering Logic
//
// Both name and tag filters follow the same precedence rules:
//
// 1. If exclude patterns/tags are specified and match -> exclude (precedence)
// 2. If include patterns/tags are specified and match -> include
// 3. If include patterns/tags are specified but no match -> exclude
// 4. If only exclude patterns/tags specified and no match -> include
// 5. If no filters specified -> include (default behavior)
//
// For a server to be included in the final registry, it must pass BOTH
// name and tag filtering (logical AND).
//
// # Usage Example
//
// service := NewDefaultFilterService()
// filter := &mcpv1alpha1.RegistryFilter{
// NameFilters: &mcpv1alpha1.NameFilter{
// Include: []string{"postgres-*", "mysql-*"},
// Exclude: []string{"*-experimental"},
// },
// Tags: &mcpv1alpha1.TagFilter{
// Include: []string{"database", "sql"},
// Exclude: []string{"deprecated"},
// },
// }
//
// filteredRegistry, err := service.ApplyFilters(ctx, originalRegistry, filter)
//
// # Detailed Logging
//
// The filtering system provides detailed logging with specific reasons for
// inclusion or exclusion decisions, making it easy to debug filtering
// configurations and understand filtering behavior.
package filtering
182 changes: 182 additions & 0 deletions cmd/thv-operator/pkg/filtering/filter_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package filtering

import (
"context"
"fmt"
"strings"

"sigs.k8s.io/controller-runtime/pkg/log"

mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
"github.com/stacklok/toolhive/pkg/registry"
)

// FilterService coordinates name and tag filtering to apply registry filters
type FilterService interface {
// ApplyFilters filters the registry based on MCPRegistry filter configuration
ApplyFilters(ctx context.Context, reg *registry.Registry, filter *mcpv1alpha1.RegistryFilter) (*registry.Registry, error)
}

// DefaultFilterService implements filtering coordination using name and tag filters
type DefaultFilterService struct {
nameFilter NameFilter
tagFilter TagFilter
}

// NewDefaultFilterService creates a new DefaultFilterService with default filter implementations
func NewDefaultFilterService() *DefaultFilterService {
return &DefaultFilterService{
nameFilter: NewDefaultNameFilter(),
tagFilter: NewDefaultTagFilter(),
}
}

// NewFilterService creates a new DefaultFilterService with custom filter implementations
func NewFilterService(nameFilter NameFilter, tagFilter TagFilter) *DefaultFilterService {
return &DefaultFilterService{
nameFilter: nameFilter,
tagFilter: tagFilter,
}
}

// ApplyFilters filters the registry based on MCPRegistry filter configuration
//
// The filtering process:
// 1. If no filter is specified, return the original registry unchanged
// 2. Create a new registry with the same metadata but empty server maps
// 3. For each server (both container and remote), apply name and tag filtering
// 4. Only include servers that pass both name and tag filters
// 5. Return the filtered registry
func (s *DefaultFilterService) ApplyFilters(
ctx context.Context,
reg *registry.Registry,
filter *mcpv1alpha1.RegistryFilter) (*registry.Registry, error) {
ctxLogger := log.FromContext(ctx)

// If no filter is specified, return original registry
if filter == nil {
ctxLogger.Info("No filter specified, returning original registry")
return reg, nil
}

ctxLogger.Info("Applying registry filters",
"originalServerCount", len(reg.Servers),
"originalRemoteServerCount", len(reg.RemoteServers))

// Create a new filtered registry with same metadata
filteredRegistry := &registry.Registry{
Version: reg.Version,
LastUpdated: reg.LastUpdated,
Servers: make(map[string]*registry.ImageMetadata),
RemoteServers: make(map[string]*registry.RemoteServerMetadata),
Groups: reg.Groups, // Groups are not filtered for now
}

// Extract filter criteria
var nameInclude, nameExclude, tagInclude, tagExclude []string
if filter.NameFilters != nil {
nameInclude = filter.NameFilters.Include
nameExclude = filter.NameFilters.Exclude
}
if filter.Tags != nil {
tagInclude = filter.Tags.Include
tagExclude = filter.Tags.Exclude
}

includedCount := 0
excludedCount := 0

// Filter container servers
for serverName, serverMetadata := range reg.Servers {
included, reason := s.shouldIncludeServerWithReason(
serverName,
serverMetadata.Tags,
nameInclude,
nameExclude,
tagInclude,
tagExclude,
)
if included {
filteredRegistry.Servers[serverName] = serverMetadata
includedCount++
ctxLogger.Info("Including container server",
"name", serverName,
"tags", serverMetadata.Tags,
"reason", reason)
} else {
excludedCount++
ctxLogger.Info("Excluding container server",
"name", serverName,
"tags", serverMetadata.Tags,
"reason", reason)
}
}

// Filter remote servers
for serverName, serverMetadata := range reg.RemoteServers {
included, reason := s.shouldIncludeServerWithReason(
serverName,
serverMetadata.Tags,
nameInclude,
nameExclude,
tagInclude,
tagExclude,
)
if included {
filteredRegistry.RemoteServers[serverName] = serverMetadata
includedCount++
ctxLogger.Info("Including remote server",
"name", serverName,
"tags", serverMetadata.Tags,
"reason", reason)
} else {
excludedCount++
ctxLogger.Info("Excluding remote server",
"name", serverName,
"tags", serverMetadata.Tags,
"reason", reason)
}
}

ctxLogger.Info("Registry filtering completed",
"includedServers", includedCount,
"excludedServers", excludedCount,
"filteredServerCount", len(filteredRegistry.Servers),
"filteredRemoteServerCount", len(filteredRegistry.RemoteServers))

return filteredRegistry, nil
}

// shouldIncludeServerWithReason determines if a server should be included and provides detailed reasoning
// Both name and tag filters must pass for a server to be included
func (s *DefaultFilterService) shouldIncludeServerWithReason(
serverName string,
serverTags []string,
nameInclude, nameExclude, tagInclude, tagExclude []string) (bool, string) {
// Apply name filtering first
nameIncluded, nameReason := s.nameFilter.ShouldInclude(serverName, nameInclude, nameExclude)
if !nameIncluded {
return false, fmt.Sprintf("name filter: %s", nameReason)
}

// Apply tag filtering
tagIncluded, tagReason := s.tagFilter.ShouldInclude(serverTags, tagInclude, tagExclude)
if !tagIncluded {
return false, fmt.Sprintf("tag filter: %s", tagReason)
}

// Both filters passed - determine the inclusion reason
inclusionReasons := []string{}
if len(nameInclude) > 0 || len(nameExclude) > 0 {
inclusionReasons = append(inclusionReasons, fmt.Sprintf("name filter: %s", nameReason))
}
if len(tagInclude) > 0 || len(tagExclude) > 0 {
inclusionReasons = append(inclusionReasons, fmt.Sprintf("tag filter: %s", tagReason))
}

if len(inclusionReasons) == 0 {
return true, "no filters specified, default include"
}

return true, "passed all filters: " + strings.Join(inclusionReasons, " AND ")
}
Loading
Loading