Skip to content

Commit 5d74672

Browse files
Merge pull request #423 from slimslenderslacks/import-json-profile
fix catalog server filtering and implement bm25 search
2 parents e765d96 + 5248c90 commit 5d74672

File tree

9 files changed

+1234
-203
lines changed

9 files changed

+1234
-203
lines changed

pkg/gateway/activateprofile.go

Lines changed: 202 additions & 62 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
37+
}
38+
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)
3148
}
32-
defer dao.Close()
3349

34-
// Create a temporary WorkingSetConfiguration to load the profile
35-
wsConfig := NewWorkingSetConfiguration(
36-
Config{WorkingSet: profileName},
37-
oci.NewService(),
38-
g.docker,
39-
)
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+
}
64+
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+
}
4083

41-
// Load the full profile configuration using the existing readOnce method
42-
profileConfig, err := wsConfig.readOnce(ctx, dao)
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 {
@@ -86,54 +147,75 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
86147
serverConfigMap := profileConfig.config[serverName]
87148

88149
for _, configItem := range serverConfig.Config {
89-
// Config items should be schema objects with a "name" property
150+
// Config items are object schemas with a "properties" map.
151+
// The "name" field is just an identifier, not a key in serverConfigMap.
90152
schemaMap, ok := configItem.(map[string]any)
91153
if !ok {
92154
continue
93155
}
94156

95-
// Get the name field - this identifies which config to validate
96-
configName, ok := schemaMap["name"].(string)
97-
if !ok || configName == "" {
157+
properties, ok := schemaMap["properties"].(map[string]any)
158+
if !ok {
98159
continue
99160
}
100161

101-
// Get the actual config value to validate
102-
if serverConfigMap == nil {
103-
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (missing)", configName))
104-
continue
162+
// Build a set of required property names
163+
requiredProps := make(map[string]bool)
164+
if requiredList, ok := schemaMap["required"].([]any); ok {
165+
for _, r := range requiredList {
166+
if s, ok := r.(string); ok {
167+
requiredProps[s] = true
168+
}
169+
}
105170
}
106171

107-
configValue := serverConfigMap
172+
// Validate each property individually
173+
for propName, propSchema := range properties {
174+
propSchemaMap, ok := propSchema.(map[string]any)
175+
if !ok {
176+
continue
177+
}
108178

109-
// Convert the schema map to a jsonschema.Schema for validation
110-
schemaBytes, err := json.Marshal(schemaMap)
111-
if err != nil {
112-
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", configName))
113-
continue
114-
}
179+
// Get the value from the user-provided config
180+
configValue, exists := serverConfigMap[propName]
181+
if !exists {
182+
// If the property has a default, the server will use it
183+
if _, hasDefault := propSchemaMap["default"]; hasDefault {
184+
continue
185+
}
186+
// Only flag as missing if explicitly required
187+
if requiredProps[propName] {
188+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (missing)", propName))
189+
}
190+
continue
191+
}
115192

116-
var schema jsonschema.Schema
117-
if err := json.Unmarshal(schemaBytes, &schema); err != nil {
118-
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", configName))
119-
continue
120-
}
193+
// Validate the value against the property schema
194+
schemaBytes, err := json.Marshal(propSchemaMap)
195+
if err != nil {
196+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
197+
continue
198+
}
121199

122-
// Resolve the schema
123-
resolved, err := schema.Resolve(nil)
124-
if err != nil {
125-
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (schema resolution failed)", configName))
126-
continue
127-
}
200+
var propSchemaObj jsonschema.Schema
201+
if err := json.Unmarshal(schemaBytes, &propSchemaObj); err != nil {
202+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (invalid schema)", propName))
203+
continue
204+
}
128205

129-
// Validate the config value against the schema
130-
if err := resolved.Validate(configValue); err != nil {
131-
// Extract a helpful error message
132-
errMsg := err.Error()
133-
if len(errMsg) > 100 {
134-
errMsg = errMsg[:97] + "..."
206+
resolved, err := propSchemaObj.Resolve(nil)
207+
if err != nil {
208+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (schema resolution failed)", propName))
209+
continue
210+
}
211+
212+
if err := resolved.Validate(configValue); err != nil {
213+
errMsg := err.Error()
214+
if len(errMsg) > 100 {
215+
errMsg = errMsg[:97] + "..."
216+
}
217+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (%s)", propName, errMsg))
135218
}
136-
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (%s)", configName, errMsg))
137219
}
138220
}
139221
}
@@ -155,7 +237,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
155237
// If any validation errors, return detailed error message
156238
if len(validationErrors) > 0 {
157239
var errorMessages []string
158-
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", profileName, len(validationErrors)))
240+
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", ws.Name, len(validationErrors)))
159241

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

179261
// All validations passed - merge configuration into current gateway
262+
// Acquire configuration mutex to ensure atomic updates
263+
g.configurationMu.Lock()
264+
defer g.configurationMu.Unlock()
265+
180266
var activatedServers []string
267+
var failedServers []string
181268

182269
// Merge secrets once (they're already namespaced in profileConfig)
183270
for secretName, secretValue := range profileConfig.secrets {
@@ -211,6 +298,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
211298
oldCaps, err := g.reloadServerCapabilities(ctx, serverName, nil)
212299
if err != nil {
213300
log.Log(fmt.Sprintf("Warning: Failed to reload capabilities for server '%s': %v", serverName, err))
301+
failedServers = append(failedServers, serverName)
214302
// Continue with other servers even if this one fails
215303
continue
216304
}
@@ -221,6 +309,7 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
221309
if err := g.updateServerCapabilities(serverName, oldCaps, newCaps, nil); err != nil {
222310
g.capabilitiesMu.Unlock()
223311
log.Log(fmt.Sprintf("Warning: Failed to update server capabilities for '%s': %v", serverName, err))
312+
failedServers = append(failedServers, serverName)
224313
// Continue with other servers even if this one fails
225314
continue
226315
}
@@ -229,10 +318,20 @@ func (g *Gateway) ActivateProfile(ctx context.Context, profileName string) error
229318
activatedServers = append(activatedServers, serverName)
230319
}
231320

232-
log.Log(fmt.Sprintf("- Successfully activated profile '%s' with %d server(s): %s", profileName, len(activatedServers), strings.Join(activatedServers, ", ")))
321+
// Log results
322+
if len(activatedServers) > 0 {
323+
log.Log(fmt.Sprintf("- Successfully activated profile '%s' with %d server(s): %s", ws.Name, len(activatedServers), strings.Join(activatedServers, ", ")))
324+
}
233325
if len(skippedServers) > 0 {
234326
log.Log(fmt.Sprintf("- Skipped %d already-active server(s): %s", len(skippedServers), strings.Join(skippedServers, ", ")))
235327
}
328+
if len(failedServers) > 0 {
329+
log.Log(fmt.Sprintf("- Failed to activate %d server(s): %s", len(failedServers), strings.Join(failedServers, ", ")))
330+
// Return error if all servers failed to activate
331+
if len(activatedServers) == 0 {
332+
return fmt.Errorf("failed to activate any servers from profile '%s'", ws.Name)
333+
}
334+
}
236335

237336
return nil
238337
}
@@ -271,8 +370,49 @@ func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
271370

272371
profileName := strings.TrimSpace(params.Name)
273372

274-
// Use the ActivateProfile method
275-
err = g.ActivateProfile(ctx, profileName)
373+
// Load the profile from either profiles.json or database
374+
var ws *workingset.WorkingSet
375+
376+
// First, try to load from project's profiles.json
377+
projectProfile, err := loadProfileFromProject(ctx, profileName)
378+
if err != nil && !errors.Is(err, errProfileNotFound) {
379+
log.Log(fmt.Sprintf("Warning: Failed to check project profiles: %v", err))
380+
}
381+
382+
if projectProfile != nil {
383+
// Found in project's profiles.json
384+
log.Log(fmt.Sprintf("- Found profile '%s' in project's profiles.json", profileName))
385+
ws = projectProfile
386+
} else {
387+
// Not found in project, try database
388+
log.Log(fmt.Sprintf("- Profile '%s' not found in project's profiles.json, checking database", profileName))
389+
390+
dao, err := db.New()
391+
if err != nil {
392+
return nil, fmt.Errorf("failed to create database client: %w", err)
393+
}
394+
defer dao.Close()
395+
396+
dbProfile, err := dao.GetWorkingSet(ctx, profileName)
397+
if err != nil {
398+
if errors.Is(err, sql.ErrNoRows) {
399+
return &mcp.CallToolResult{
400+
Content: []mcp.Content{&mcp.TextContent{
401+
Text: fmt.Sprintf("Error: Profile '%s' not found in project or database", profileName),
402+
}},
403+
IsError: true,
404+
}, nil
405+
}
406+
return nil, fmt.Errorf("failed to load profile from database: %w", err)
407+
}
408+
409+
log.Log(fmt.Sprintf("- Found profile '%s' in database", profileName))
410+
wsFromDb := workingset.NewFromDb(dbProfile)
411+
ws = &wsFromDb
412+
}
413+
414+
// Activate the profile
415+
err = g.ActivateProfile(ctx, *ws)
276416
if err != nil {
277417
return &mcp.CallToolResult{
278418
Content: []mcp.Content{&mcp.TextContent{
@@ -284,7 +424,7 @@ func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
284424

285425
return &mcp.CallToolResult{
286426
Content: []mcp.Content{&mcp.TextContent{
287-
Text: fmt.Sprintf("Successfully activated profile '%s'", profileName),
427+
Text: fmt.Sprintf("Successfully activated profile '%s'", ws.Name),
288428
}},
289429
}, nil
290430
}

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

0 commit comments

Comments
 (0)