Skip to content

Commit b982681

Browse files
implement BM25 search and fix catalog server filtering
This commit introduces significant improvements to server discovery and profile management: ## BM25 Search Implementation - Replace keyword search with Okapi BM25 ranking algorithm for mcp-find - Implement field weighting (name ×4, title ×3, tools ×2, description ×1) - Add comprehensive test suite with 8 test scenarios - Add timing logs for index build, scoring, and total query time - Build fresh index on each query to include dynamically activated servers ## Critical Bug Fix: Catalog Server Filtering - Fix FilterByPolicy removing all catalog servers from mcp-find results - Preserve catalog servers in configuration.servers map for search - Only apply policy filtering to enabled servers in serverNames - Result: mcp-find now searches all 340 servers (1 enabled + 339 catalog) ## Profile Management Improvements - Refactor ProfileActivator interface to accept WorkingSet directly - Separate profile loading from activation logic - Add profiles.json support for project-level profiles - Implement atomic file writes with temp file + rename pattern - Add comprehensive tests for SaveProfile and LoadProfiles - Add mutex protection for concurrent configuration access ## Breaking Changes - ProfileActivator.ActivateProfile now takes WorkingSet instead of string
1 parent 9fe7b05 commit b982681

File tree

9 files changed

+1187
-171
lines changed

9 files changed

+1187
-171
lines changed

pkg/gateway/activateprofile.go

Lines changed: 154 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,105 @@ package gateway
22

33
import (
44
"context"
5+
"database/sql"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"slices"
810
"strings"
911

1012
"github.com/google/jsonschema-go/jsonschema"
1113
"github.com/modelcontextprotocol/go-sdk/mcp"
1214

15+
"github.com/docker/mcp-gateway/pkg/catalog"
16+
"github.com/docker/mcp-gateway/pkg/config"
1317
"github.com/docker/mcp-gateway/pkg/db"
18+
"github.com/docker/mcp-gateway/pkg/gateway/project"
1419
"github.com/docker/mcp-gateway/pkg/log"
1520
"github.com/docker/mcp-gateway/pkg/oci"
21+
"github.com/docker/mcp-gateway/pkg/workingset"
1622
)
1723

18-
// ActivateProfileResult contains the result of profile activation
19-
type ActivateProfileResult struct {
20-
ActivatedServers []string
21-
SkippedServers []string
22-
ErrorMessage string
23-
}
24+
var errProfileNotFound = errors.New("profile not found")
2425

25-
// ActivateProfile activates a profile by name, loading its servers into the gateway
26-
func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error {
27-
// Create database connection
28-
dao, err := db.New()
26+
// loadProfileFromProject attempts to load a profile from the project's profiles.json
27+
// Returns the WorkingSet if found, or errProfileNotFound if not found
28+
func loadProfileFromProject(ctx context.Context, profileName string) (*workingset.WorkingSet, error) {
29+
profiles, err := project.LoadProfiles(ctx)
2930
if err != nil {
30-
return fmt.Errorf("failed to create database client: %w", err)
31+
return nil, fmt.Errorf("failed to load profiles.json: %w", err)
32+
}
33+
34+
if profile, found := profiles[profileName]; found {
35+
log.Log(fmt.Sprintf("- Found profile '%s' in project's profiles.json", profileName))
36+
return &profile, nil
3137
}
32-
defer dao.Close()
3338

34-
// Create a temporary WorkingSetConfiguration to load the profile
35-
wsConfig := NewWorkingSetConfiguration(
36-
Config{WorkingSet: profileName},
37-
oci.NewService(),
38-
g.docker,
39-
)
39+
return nil, errProfileNotFound
40+
}
41+
42+
// convertWorkingSetToConfiguration converts a WorkingSet to a Configuration object
43+
func (g *Gateway) convertWorkingSetToConfiguration(ctx context.Context, ws workingset.WorkingSet) (Configuration, error) {
44+
// Ensure snapshots are resolved
45+
ociService := oci.NewService()
46+
if err := ws.EnsureSnapshotsResolved(ctx, ociService); err != nil {
47+
return Configuration{}, fmt.Errorf("failed to resolve snapshots: %w", err)
48+
}
49+
50+
// Build configuration similar to WorkingSetConfiguration.readOnce
51+
cfg := make(map[string]map[string]any)
52+
configs := make([]ServerSecretConfig, 0, len(ws.Servers))
53+
toolsConfig := config.ToolsConfig{ServerTools: make(map[string][]string)}
54+
serverNames := make([]string, 0)
55+
servers := make(map[string]catalog.Server)
56+
57+
for _, server := range ws.Servers {
58+
// Skip non-image/remote/registry servers
59+
if server.Type != workingset.ServerTypeImage &&
60+
server.Type != workingset.ServerTypeRemote &&
61+
server.Type != workingset.ServerTypeRegistry {
62+
continue
63+
}
4064

41-
// Load the full profile configuration using the existing readOnce method
42-
profileConfig, err := wsConfig.readOnce(ctx, dao)
65+
serverName := server.Snapshot.Server.Name
66+
servers[serverName] = server.Snapshot.Server
67+
serverNames = append(serverNames, serverName)
68+
cfg[serverName] = server.Config
69+
70+
// Build secrets configs
71+
namespace := ""
72+
configs = append(configs, ServerSecretConfig{
73+
Secrets: server.Snapshot.Server.Secrets,
74+
OAuth: server.Snapshot.Server.OAuth,
75+
Namespace: namespace,
76+
})
77+
78+
// Add tools
79+
if server.Tools != nil {
80+
toolsConfig.ServerTools[serverName] = server.Tools
81+
}
82+
}
83+
84+
secrets := BuildSecretsURIs(ctx, configs)
85+
86+
return Configuration{
87+
serverNames: serverNames,
88+
servers: servers,
89+
config: cfg,
90+
tools: toolsConfig,
91+
secrets: secrets,
92+
}, nil
93+
}
94+
95+
// ActivateProfile activates a profile by merging its servers into the gateway
96+
// The WorkingSet should be loaded by the caller before calling this method
97+
func (g *Gateway) ActivateProfile(ctx context.Context, ws workingset.WorkingSet) error {
98+
log.Log(fmt.Sprintf("- Activating profile '%s'", ws.Name))
99+
100+
// Convert WorkingSet to Configuration
101+
profileConfig, err := g.convertWorkingSetToConfiguration(ctx, ws)
43102
if err != nil {
44-
return fmt.Errorf("failed to load profile '%s': %w", profileName, err)
103+
return fmt.Errorf("failed to convert profile '%s': %w", ws.Name, err)
45104
}
46105

47106
// Filter servers: only activate servers that are not already active
@@ -59,14 +118,16 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
59118
// If no servers to activate, return early
60119
if len(serversToActivate) == 0 {
61120
if len(skippedServers) > 0 {
62-
log.Log(fmt.Sprintf("- All servers from profile '%s' are already active: %s", profileName, strings.Join(skippedServers, ", ")))
121+
log.Log(fmt.Sprintf("- All servers from profile '%s' are already active: %s", ws.Name, strings.Join(skippedServers, ", ")))
63122
} else {
64-
log.Log(fmt.Sprintf("- No new servers to activate from profile '%s'", profileName))
123+
log.Log(fmt.Sprintf("- No new servers to activate from profile '%s'", ws.Name))
65124
}
66125
return nil
67126
}
68127

69-
// Validate ALL servers before activating any (all-or-nothing)
128+
// Validate ALL servers before activating any
129+
// Note: Validation ensures prerequisites (secrets, config, images) are met.
130+
// Actual capability loading happens during activation and may partially succeed.
70131
var validationErrors []serverValidation
71132

72133
for _, serverName := range serversToActivate {
@@ -104,7 +165,12 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
104165
continue
105166
}
106167

107-
configValue := serverConfigMap
168+
// Extract the specific config value for this schema, not the entire map
169+
configValue, exists := serverConfigMap[configName]
170+
if !exists {
171+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (missing)", configName))
172+
continue
173+
}
108174

109175
// Convert the schema map to a jsonschema.Schema for validation
110176
schemaBytes, err := json.Marshal(schemaMap)
@@ -155,7 +221,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
155221
// If any validation errors, return detailed error message
156222
if len(validationErrors) > 0 {
157223
var errorMessages []string
158-
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", profileName, len(validationErrors)))
224+
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", ws.Name, len(validationErrors)))
159225

160226
for _, validation := range validationErrors {
161227
errorMessages = append(errorMessages, fmt.Sprintf("\nServer '%s':", validation.serverName))
@@ -177,7 +243,12 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
177243
}
178244

179245
// All validations passed - merge configuration into current gateway
246+
// Acquire configuration mutex to ensure atomic updates
247+
g.configurationMu.Lock()
248+
defer g.configurationMu.Unlock()
249+
180250
var activatedServers []string
251+
var failedServers []string
181252

182253
// Merge secrets once (they're already namespaced in profileConfig)
183254
for secretName, secretValue := range profileConfig.secrets {
@@ -211,6 +282,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
211282
oldCaps, err := g.reloadServerCapabilities(ctx, serverName, nil)
212283
if err != nil {
213284
log.Log(fmt.Sprintf("Warning: Failed to reload capabilities for server '%s': %v", serverName, err))
285+
failedServers = append(failedServers, serverName)
214286
// Continue with other servers even if this one fails
215287
continue
216288
}
@@ -221,6 +293,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
221293
if err := g.updateServerCapabilities(serverName, oldCaps, newCaps, nil); err != nil {
222294
g.capabilitiesMu.Unlock()
223295
log.Log(fmt.Sprintf("Warning: Failed to update server capabilities for '%s': %v", serverName, err))
296+
failedServers = append(failedServers, serverName)
224297
// Continue with other servers even if this one fails
225298
continue
226299
}
@@ -229,10 +302,20 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
229302
activatedServers = append(activatedServers, serverName)
230303
}
231304

232-
log.Log(fmt.Sprintf("- Successfully activated profile '%s' with %d server(s): %s", profileName, len(activatedServers), strings.Join(activatedServers, ", ")))
305+
// Log results
306+
if len(activatedServers) > 0 {
307+
log.Log(fmt.Sprintf("- Successfully activated profile '%s' with %d server(s): %s", ws.Name, len(activatedServers), strings.Join(activatedServers, ", ")))
308+
}
233309
if len(skippedServers) > 0 {
234310
log.Log(fmt.Sprintf("- Skipped %d already-active server(s): %s", len(skippedServers), strings.Join(skippedServers, ", ")))
235311
}
312+
if len(failedServers) > 0 {
313+
log.Log(fmt.Sprintf("- Failed to activate %d server(s): %s", len(failedServers), strings.Join(failedServers, ", ")))
314+
// Return error if all servers failed to activate
315+
if len(activatedServers) == 0 {
316+
return fmt.Errorf("failed to activate any servers from profile '%s'", ws.Name)
317+
}
318+
}
236319

237320
return nil
238321
}
@@ -271,8 +354,49 @@ func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
271354

272355
profileName := strings.TrimSpace(params.Name)
273356

274-
// Use the ActivateProfile method
275-
err = g.ActivateProfile(ctx, profileName)
357+
// Load the profile from either profiles.json or database
358+
var ws *workingset.WorkingSet
359+
360+
// First, try to load from project's profiles.json
361+
projectProfile, err := loadProfileFromProject(ctx, profileName)
362+
if err != nil && !errors.Is(err, errProfileNotFound) {
363+
log.Log(fmt.Sprintf("Warning: Failed to check project profiles: %v", err))
364+
}
365+
366+
if projectProfile != nil {
367+
// Found in project's profiles.json
368+
log.Log(fmt.Sprintf("- Found profile '%s' in project's profiles.json", profileName))
369+
ws = projectProfile
370+
} else {
371+
// Not found in project, try database
372+
log.Log(fmt.Sprintf("- Profile '%s' not found in project's profiles.json, checking database", profileName))
373+
374+
dao, err := db.New()
375+
if err != nil {
376+
return nil, fmt.Errorf("failed to create database client: %w", err)
377+
}
378+
defer dao.Close()
379+
380+
dbProfile, err := dao.GetWorkingSet(ctx, profileName)
381+
if err != nil {
382+
if errors.Is(err, sql.ErrNoRows) {
383+
return &mcp.CallToolResult{
384+
Content: []mcp.Content{&mcp.TextContent{
385+
Text: fmt.Sprintf("Error: Profile '%s' not found in project or database", profileName),
386+
}},
387+
IsError: true,
388+
}, nil
389+
}
390+
return nil, fmt.Errorf("failed to load profile from database: %w", err)
391+
}
392+
393+
log.Log(fmt.Sprintf("- Found profile '%s' in database", profileName))
394+
wsFromDb := workingset.NewFromDb(dbProfile)
395+
ws = &wsFromDb
396+
}
397+
398+
// Activate the profile
399+
err = g.ActivateProfile(ctx, *ws)
276400
if err != nil {
277401
return &mcp.CallToolResult{
278402
Content: []mcp.Content{&mcp.TextContent{
@@ -284,7 +408,7 @@ func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
284408

285409
return &mcp.CallToolResult{
286410
Content: []mcp.Content{&mcp.TextContent{
287-
Text: fmt.Sprintf("Successfully activated profile '%s'", profileName),
411+
Text: fmt.Sprintf("Successfully activated profile '%s'", ws.Name),
288412
}},
289413
}, nil
290414
}

pkg/gateway/configuration.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@ func (c *Configuration) FilterByPolicy(ctx context.Context, pc policy.Client) er
209209
}
210210

211211
// Apply filtering based on batch results.
212-
filteredServers := make(map[string]catalog.Server)
212+
// Start with all existing servers (to preserve catalog servers for mcp-find)
213+
filteredServers := make(map[string]catalog.Server, len(c.servers))
214+
for name, server := range c.servers {
215+
filteredServers[name] = server
216+
}
217+
213218
filteredServerNames := make([]string, 0, len(c.serverNames))
214219
filteredConfig := make(map[string]map[string]any)
215220
filteredTools := config.ToolsConfig{
@@ -218,6 +223,8 @@ func (c *Configuration) FilterByPolicy(ctx context.Context, pc policy.Client) er
218223

219224
for _, name := range c.serverNames {
220225
if !allowedServers[name] {
226+
// Remove denied enabled servers from the servers map
227+
delete(filteredServers, name)
221228
continue
222229
}
223230

pkg/gateway/createprofile.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func createProfileHandler(g *Gateway) mcp.ToolHandler {
177177
// If client is claude-code, update profiles.json in current directory
178178
clientInfo := req.Session.InitializeParams().ClientInfo
179179
// SaveProfileForClient handles Claude Code detection and profile saving
180-
_ = project.SaveProfileForClient(clientInfo, profileName)
180+
_ = project.SaveProfileForClient(clientInfo, ws)
181181

182182
// Build success message
183183
var resultMessage string

0 commit comments

Comments
 (0)