Skip to content

Commit 362b068

Browse files
Slim/dynamic profiles (#313)
* Add conditional profiles feature with Claude Code integration This commit implements several enhancements to the profiles feature: 1. Conditional tool loading based on profiles feature flag - Added UseProfiles field to Options config - Profile tools (mcp-create-profile, mcp-activate-profile) now only load when features.IsProfilesFeatureEnabled() returns true - Updated gateway.go to set the flag based on feature detection - Modified reload.go to conditionally register profile tools 2. Automatic profile loading for Claude Code - Created new pkg/gateway/project package for project-level config - LoadProfiles() reads profiles.json from current working directory - InitializedHandler detects claude-code client and auto-loads profiles - Profiles are activated on connection startup seamlessly 3. Automatic profiles.json management for Claude Code - SaveProfile() function creates/updates profiles.json - When creating a profile with claude-code client, profile name is automatically added to profiles.json in current directory - Maintains array of profile names with deduplication 4. Refactored profile activation for reusability - Extracted ActivateProfile() method on Gateway - Performs full validation (secrets, config, image pulls) - Used by both activateProfileHandler and LoadProfiles - Simplified activateProfileHandler by delegating to method 5. Enhanced initialization logging - Changed InitializedHandler to log entire initialize request - Provides complete visibility into client capabilities and config - Helpful for debugging client behavior 6. Improved Configurator interface design - Added readDockerDesktopSecrets to Configurator interface - Eliminated type-casting in activateprofile.go and mcpadd.go - Both FileBasedConfiguration and WorkingSetConfiguration implement - Cleaner code following proper Go interface patterns * stop skipping remote servers * Claude-Code support in project package. * update permissions * Updates from review * Update pkg/gateway/activateprofile.go Co-authored-by: Cody Rigney <cody.rigney@docker.com> * slight mishap! * another mistake * while testing, found a bug in docker mcp secret * Move profile loading into middleware It's better if this happens before the client sends the initialized notification
1 parent 83c8ab5 commit 362b068

File tree

13 files changed

+658
-43
lines changed

13 files changed

+658
-43
lines changed

.claude/settings.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(cat:*)",
5+
"Bash(grep:*)",
6+
"Bash(go test:*)",
7+
"Bash(go build:*)",
8+
"Bash(make:*)",
9+
"Bash(go test :*)",
10+
"Bash(go run :*)",
11+
"Bash(go build :*)",
12+
"Bash(go mod :*)",
13+
"Bash(go vet :*)",
14+
"Bash(docker ps:*)",
15+
"Bash(docker images:*)",
16+
"Bash(docker logs:*)",
17+
"Bash(docker inspect:*)",
18+
"Bash(git status:*)",
19+
"Bash(git diff:*)",
20+
"Bash(git log:*)",
21+
"Bash(git show:*)",
22+
"Bash(git branch:*)",
23+
"Bash(ls :*)",
24+
"Bash(pwd)",
25+
"Bash(head :*)",
26+
"Bash(tail :*)"
27+
],
28+
"deny": [
29+
"Bash(rm -rf:*)",
30+
"Bash(docker system prune:*)",
31+
"Read(./.env)",
32+
"Read(./.env.*)",
33+
"Read(./secrets/**)",
34+
"Write(./.env*)",
35+
"Edit(./.env*)"
36+
],
37+
"ask": [
38+
"Bash(git add:*)",
39+
"Bash(git commit:*)",
40+
"Bash(git push:*)",
41+
"Bash(docker run:*)",
42+
"Bash(docker start:*)",
43+
"Bash(docker stop:*)",
44+
"Bash(docker rm:*)",
45+
"Bash(docker rmi:*)",
46+
"Write"
47+
]
48+
},
49+
"enabledPlugins": {
50+
"mcp-toolkit@docker": true
51+
}
52+
}

cmd/docker-mcp/commands/gateway.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli, features featur
102102
// Check if use-embeddings feature is enabled
103103
options.UseEmbeddings = isUseEmbeddingsFeatureEnabled(dockerCli)
104104

105+
// Check if profiles feature is enabled
106+
options.UseProfiles = features.IsProfilesFeatureEnabled()
107+
105108
// Update catalog URL based on mcp-oauth-dcr flag if using default Docker catalog URL
106109
if len(options.CatalogPath) == 1 && (options.CatalogPath[0] == catalog.DockerCatalogURLV2 || options.CatalogPath[0] == catalog.DockerCatalogURLV3) {
107110
options.CatalogPath[0] = catalog.GetDockerCatalogURL(options.McpOAuthDcrEnabled)

cmd/docker-mcp/secret-management/secret/set.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func ParseArg(arg string, opts SetOpts) (*Secret, error) {
4242
if !isDirectValueProvider(opts.Provider) {
4343
return &Secret{key: arg, val: ""}, nil
4444
}
45-
parts := strings.Split(arg, "=")
45+
parts := strings.SplitN(arg, "=", 2)
4646
if len(parts) != 2 {
4747
return nil, fmt.Errorf("no key=value pair: %s", arg)
4848
}

pkg/gateway/activateprofile.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package gateway
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"slices"
8+
"strings"
9+
10+
"github.com/google/jsonschema-go/jsonschema"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
12+
13+
"github.com/docker/mcp-gateway/pkg/db"
14+
"github.com/docker/mcp-gateway/pkg/log"
15+
"github.com/docker/mcp-gateway/pkg/oci"
16+
)
17+
18+
// ActivateProfileResult contains the result of profile activation
19+
type ActivateProfileResult struct {
20+
ActivatedServers []string
21+
SkippedServers []string
22+
ErrorMessage string
23+
}
24+
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()
29+
if err != nil {
30+
return fmt.Errorf("failed to create database client: %w", err)
31+
}
32+
defer dao.Close()
33+
34+
// Create a temporary WorkingSetConfiguration to load the profile
35+
wsConfig := NewWorkingSetConfiguration(
36+
Config{WorkingSet: profileName},
37+
oci.NewService(),
38+
g.docker,
39+
)
40+
41+
// Load the full profile configuration using the existing readOnce method
42+
profileConfig, err := wsConfig.readOnce(ctx, dao)
43+
if err != nil {
44+
return fmt.Errorf("failed to load profile '%s': %w", profileName, err)
45+
}
46+
47+
// Filter servers: only activate servers that are not already active
48+
var serversToActivate []string
49+
var skippedServers []string
50+
51+
for _, serverName := range profileConfig.serverNames {
52+
if slices.Contains(g.configuration.serverNames, serverName) {
53+
skippedServers = append(skippedServers, serverName)
54+
} else {
55+
serversToActivate = append(serversToActivate, serverName)
56+
}
57+
}
58+
59+
// If no servers to activate, return early
60+
if len(serversToActivate) == 0 {
61+
if len(skippedServers) > 0 {
62+
log.Log(fmt.Sprintf("- All servers from profile '%s' are already active: %s", profileName, strings.Join(skippedServers, ", ")))
63+
} else {
64+
log.Log(fmt.Sprintf("- No new servers to activate from profile '%s'", profileName))
65+
}
66+
return nil
67+
}
68+
69+
// Validate ALL servers before activating any (all-or-nothing)
70+
var validationErrors []serverValidation
71+
72+
for _, serverName := range serversToActivate {
73+
serverConfig := profileConfig.servers[serverName]
74+
validation := serverValidation{serverName: serverName}
75+
76+
// Check if all required secrets are set
77+
for _, secret := range serverConfig.Secrets {
78+
if value, exists := profileConfig.secrets[secret.Name]; !exists || value == "" {
79+
validation.missingSecrets = append(validation.missingSecrets, secret.Name)
80+
}
81+
}
82+
83+
// Check if all required config values are set and validate against schema
84+
if len(serverConfig.Config) > 0 {
85+
// Get config from profile
86+
serverConfigMap := profileConfig.config[serverName]
87+
88+
for _, configItem := range serverConfig.Config {
89+
// Config items should be schema objects with a "name" property
90+
schemaMap, ok := configItem.(map[string]any)
91+
if !ok {
92+
continue
93+
}
94+
95+
// Get the name field - this identifies which config to validate
96+
configName, ok := schemaMap["name"].(string)
97+
if !ok || configName == "" {
98+
continue
99+
}
100+
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
105+
}
106+
107+
configValue := serverConfigMap
108+
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+
}
115+
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+
}
121+
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+
}
128+
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] + "..."
135+
}
136+
validation.missingConfig = append(validation.missingConfig, fmt.Sprintf("%s (%s)", configName, errMsg))
137+
}
138+
}
139+
}
140+
141+
// Validate that Docker image can be pulled
142+
if serverConfig.Image != "" {
143+
log.Log(fmt.Sprintf("Validating image for server '%s': %s", serverName, serverConfig.Image))
144+
if err := g.docker.PullImage(ctx, serverConfig.Image); err != nil {
145+
validation.imagePullError = err
146+
}
147+
}
148+
149+
// Collect validation errors
150+
if len(validation.missingSecrets) > 0 || len(validation.missingConfig) > 0 || validation.imagePullError != nil {
151+
validationErrors = append(validationErrors, validation)
152+
}
153+
}
154+
155+
// If any validation errors, return detailed error message
156+
if len(validationErrors) > 0 {
157+
var errorMessages []string
158+
errorMessages = append(errorMessages, fmt.Sprintf("Cannot activate profile '%s'. Validation failed for %d server(s):", profileName, len(validationErrors)))
159+
160+
for _, validation := range validationErrors {
161+
errorMessages = append(errorMessages, fmt.Sprintf("\nServer '%s':", validation.serverName))
162+
163+
if len(validation.missingSecrets) > 0 {
164+
errorMessages = append(errorMessages, fmt.Sprintf(" Missing secrets: %s", strings.Join(validation.missingSecrets, ", ")))
165+
}
166+
167+
if len(validation.missingConfig) > 0 {
168+
errorMessages = append(errorMessages, fmt.Sprintf(" Missing/invalid config: %s", strings.Join(validation.missingConfig, ", ")))
169+
}
170+
171+
if validation.imagePullError != nil {
172+
errorMessages = append(errorMessages, fmt.Sprintf(" Image pull failed: %v", validation.imagePullError))
173+
}
174+
}
175+
176+
return fmt.Errorf("%s", strings.Join(errorMessages, "\n"))
177+
}
178+
179+
// All validations passed - merge configuration into current gateway
180+
var activatedServers []string
181+
182+
// Merge secrets once (they're already namespaced in profileConfig)
183+
for secretName, secretValue := range profileConfig.secrets {
184+
g.configuration.secrets[secretName] = secretValue
185+
}
186+
187+
for _, serverName := range serversToActivate {
188+
// Add server name to the list
189+
g.configuration.serverNames = append(g.configuration.serverNames, serverName)
190+
191+
// Add server definition
192+
g.configuration.servers[serverName] = profileConfig.servers[serverName]
193+
194+
// Merge server config
195+
if profileConfig.config[serverName] != nil {
196+
if g.configuration.config == nil {
197+
g.configuration.config = make(map[string]map[string]any)
198+
}
199+
g.configuration.config[serverName] = profileConfig.config[serverName]
200+
}
201+
202+
// Merge tools configuration
203+
if tools, exists := profileConfig.tools.ServerTools[serverName]; exists {
204+
if g.configuration.tools.ServerTools == nil {
205+
g.configuration.tools.ServerTools = make(map[string][]string)
206+
}
207+
g.configuration.tools.ServerTools[serverName] = tools
208+
}
209+
210+
// Reload server capabilities
211+
oldCaps, err := g.reloadServerCapabilities(ctx, serverName, nil)
212+
if err != nil {
213+
log.Log(fmt.Sprintf("Warning: Failed to reload capabilities for server '%s': %v", serverName, err))
214+
// Continue with other servers even if this one fails
215+
continue
216+
}
217+
218+
// Update g.mcpServer with the new capabilities
219+
g.capabilitiesMu.Lock()
220+
newCaps := g.allCapabilities(serverName)
221+
if err := g.updateServerCapabilities(serverName, oldCaps, newCaps, nil); err != nil {
222+
g.capabilitiesMu.Unlock()
223+
log.Log(fmt.Sprintf("Warning: Failed to update server capabilities for '%s': %v", serverName, err))
224+
// Continue with other servers even if this one fails
225+
continue
226+
}
227+
g.capabilitiesMu.Unlock()
228+
229+
activatedServers = append(activatedServers, serverName)
230+
}
231+
232+
log.Log(fmt.Sprintf("- Successfully activated profile '%s' with %d server(s): %s", profileName, len(activatedServers), strings.Join(activatedServers, ", ")))
233+
if len(skippedServers) > 0 {
234+
log.Log(fmt.Sprintf("- Skipped %d already-active server(s): %s", len(skippedServers), strings.Join(skippedServers, ", ")))
235+
}
236+
237+
return nil
238+
}
239+
240+
// serverValidation holds validation results for a single server
241+
type serverValidation struct {
242+
serverName string
243+
missingSecrets []string
244+
missingConfig []string
245+
imagePullError error
246+
}
247+
248+
func activateProfileHandler(g *Gateway, _ *clientConfig) mcp.ToolHandler {
249+
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
250+
// Parse profile-id parameter
251+
var params struct {
252+
Name string `json:"name"`
253+
}
254+
255+
if req.Params.Arguments == nil {
256+
return nil, fmt.Errorf("missing arguments")
257+
}
258+
259+
paramsBytes, err := json.Marshal(req.Params.Arguments)
260+
if err != nil {
261+
return nil, fmt.Errorf("failed to marshal arguments: %w", err)
262+
}
263+
264+
if err := json.Unmarshal(paramsBytes, &params); err != nil {
265+
return nil, fmt.Errorf("failed to parse arguments: %w", err)
266+
}
267+
268+
if params.Name == "" {
269+
return nil, fmt.Errorf("name parameter is required")
270+
}
271+
272+
profileName := strings.TrimSpace(params.Name)
273+
274+
// Use the ActivateProfile method
275+
err = g.ActivateProfile(ctx, profileName)
276+
if err != nil {
277+
return &mcp.CallToolResult{
278+
Content: []mcp.Content{&mcp.TextContent{
279+
Text: fmt.Sprintf("Error: %v", err),
280+
}},
281+
IsError: true,
282+
}, nil
283+
}
284+
285+
return &mcp.CallToolResult{
286+
Content: []mcp.Content{&mcp.TextContent{
287+
Text: fmt.Sprintf("Successfully activated profile '%s'", profileName),
288+
}},
289+
}, nil
290+
}
291+
}

pkg/gateway/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ type Options struct {
3838
ToolNamePrefix bool
3939
LogFilePath string
4040
UseEmbeddings bool
41+
UseProfiles bool
4142
}

0 commit comments

Comments
 (0)