Skip to content

Commit 8897622

Browse files
cmrigneybobbyhouse
andauthored
Working sets: Config command + self-describing images fully work in the gateway (#219)
* Add config set and get for working sets. * Support config and secrets for images with working sets. * Fix tests after changes. Simplify tests. * Lint fixes. * Add config deletion. * Comment update. * Update docs. * feat: Add workingsets to client output Show which workingsets a client is connected to as part of it's JSON output. * review: one working set Update the logic to only support one working set per client. * fix: lint --------- Co-authored-by: Bobby House <[email protected]>
1 parent b884649 commit 8897622

File tree

18 files changed

+1985
-168
lines changed

18 files changed

+1985
-168
lines changed

cmd/docker-mcp/client/config.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ type MCPClientCfgBase struct {
141141
Icon string `json:"icon"`
142142
ConfigName string `json:"configName"`
143143
IsMCPCatalogConnected bool `json:"dockerMCPCatalogConnected"`
144+
WorkingSet string `json:"workingset"`
144145
Err *CfgError `json:"error"`
145146

146147
cfg *MCPJSONLists
@@ -149,8 +150,10 @@ type MCPClientCfgBase struct {
149150
func (c *MCPClientCfgBase) setParseResult(lists *MCPJSONLists, err error) {
150151
c.Err = classifyError(err)
151152
if lists != nil {
152-
if containsMCPDocker(lists.STDIOServers) {
153+
server := containsMCPDocker(lists.STDIOServers)
154+
if server.Name != "" {
153155
c.IsMCPCatalogConnected = true
156+
c.WorkingSet = server.GetWorkingSet()
154157
}
155158
}
156159
c.cfg = lists

cmd/docker-mcp/client/global.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,13 @@ func (c *GlobalCfgProcessor) Update(key string, server *MCPServerSTDIO) error {
141141
return updateConfig(targetPath, c.p.Add, c.p.Del, key, server)
142142
}
143143

144-
func containsMCPDocker(in []MCPServerSTDIO) bool {
144+
func containsMCPDocker(in []MCPServerSTDIO) MCPServerSTDIO {
145145
for _, server := range in {
146146
if server.Name == DockerMCPCatalog || server.Name == makeSimpleName(DockerMCPCatalog) {
147-
return true
147+
return server
148148
}
149149
}
150-
return false
150+
return MCPServerSTDIO{}
151151
}
152152

153153
type (

cmd/docker-mcp/client/global_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,37 @@ func TestGlobalCfgProcessor_SinglePath(t *testing.T) {
211211
assert.True(t, result.IsOsSupported)
212212
assert.Nil(t, result.Err)
213213
}
214+
215+
func TestGlobalCfgProcessor_SingleWorkingSet(t *testing.T) {
216+
tempDir := t.TempDir()
217+
configPath := filepath.Join(tempDir, "config.json")
218+
219+
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run", "--working-set", "test-set"]}}}`), 0o644))
220+
221+
cfg := newTestGlobalCfg()
222+
setPathsForCurrentOS(&cfg, []string{configPath})
223+
224+
processor, err := NewGlobalCfgProcessor(cfg)
225+
require.NoError(t, err)
226+
227+
result := processor.ParseConfig()
228+
assert.True(t, result.IsMCPCatalogConnected)
229+
assert.Equal(t, "test-set", result.WorkingSet)
230+
}
231+
232+
func TestGlobalCfgProcessor_NoWorkingSet(t *testing.T) {
233+
tempDir := t.TempDir()
234+
configPath := filepath.Join(tempDir, "config.json")
235+
236+
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run"]}}}`), 0o644))
237+
238+
cfg := newTestGlobalCfg()
239+
setPathsForCurrentOS(&cfg, []string{configPath})
240+
241+
processor, err := NewGlobalCfgProcessor(cfg)
242+
require.NoError(t, err)
243+
244+
result := processor.ParseConfig()
245+
assert.True(t, result.IsMCPCatalogConnected)
246+
assert.Empty(t, result.WorkingSet)
247+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package client
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestLocalCfgProcessor_SingleWorkingSet(t *testing.T) {
13+
tempDir := t.TempDir()
14+
projectRoot := tempDir
15+
projectFile := ".cursor/mcp.json"
16+
configPath := filepath.Join(projectRoot, projectFile)
17+
18+
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
19+
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run", "--working-set", "project-ws"]}}}`), 0o644))
20+
21+
cfg := localCfg{
22+
DisplayName: "Test Client",
23+
ProjectFile: projectFile,
24+
YQ: YQ{
25+
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
26+
Set: ".mcpServers[$NAME] = $JSON",
27+
Del: "del(.mcpServers[$NAME])",
28+
},
29+
}
30+
31+
processor, err := NewLocalCfgProcessor(cfg, projectRoot)
32+
require.NoError(t, err)
33+
34+
result := processor.Parse()
35+
assert.True(t, result.IsConfigured)
36+
assert.True(t, result.IsMCPCatalogConnected)
37+
assert.Equal(t, "project-ws", result.WorkingSet)
38+
}
39+
40+
func TestLocalCfgProcessor_NoWorkingSet(t *testing.T) {
41+
tempDir := t.TempDir()
42+
projectRoot := tempDir
43+
projectFile := ".cursor/mcp.json"
44+
configPath := filepath.Join(projectRoot, projectFile)
45+
46+
require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
47+
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run"]}}}`), 0o644))
48+
49+
cfg := localCfg{
50+
DisplayName: "Test Client",
51+
ProjectFile: projectFile,
52+
YQ: YQ{
53+
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
54+
Set: ".mcpServers[$NAME] = $JSON",
55+
Del: "del(.mcpServers[$NAME])",
56+
},
57+
}
58+
59+
processor, err := NewLocalCfgProcessor(cfg, projectRoot)
60+
require.NoError(t, err)
61+
62+
result := processor.Parse()
63+
assert.True(t, result.IsConfigured)
64+
assert.True(t, result.IsMCPCatalogConnected)
65+
assert.Empty(t, result.WorkingSet)
66+
}
67+
68+
func TestLocalCfgProcessor_NotConfigured(t *testing.T) {
69+
tempDir := t.TempDir()
70+
projectRoot := tempDir
71+
projectFile := ".cursor/mcp.json"
72+
73+
cfg := localCfg{
74+
DisplayName: "Test Client",
75+
ProjectFile: projectFile,
76+
YQ: YQ{
77+
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
78+
Set: ".mcpServers[$NAME] = $JSON",
79+
Del: "del(.mcpServers[$NAME])",
80+
},
81+
}
82+
83+
processor, err := NewLocalCfgProcessor(cfg, projectRoot)
84+
require.NoError(t, err)
85+
86+
result := processor.Parse()
87+
assert.False(t, result.IsConfigured)
88+
assert.False(t, result.IsMCPCatalogConnected)
89+
assert.Empty(t, result.WorkingSet)
90+
}

cmd/docker-mcp/client/parse.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ func (c *MCPServerSTDIO) String() string {
3737
return result
3838
}
3939

40+
func (c *MCPServerSTDIO) GetWorkingSet() string {
41+
for i := range len(c.Args) {
42+
arg := c.Args[i]
43+
if arg == "--working-set" || arg == "-w" {
44+
if i+1 < len(c.Args) {
45+
return c.Args[i+1]
46+
}
47+
}
48+
}
49+
return ""
50+
}
51+
4052
type MCPServerSSE struct {
4153
Name string `json:"name"`
4254
URL string `json:"url"`

cmd/docker-mcp/commands/workingset.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,42 @@ func workingSetCommand() *cobra.Command {
2828
cmd.AddCommand(pullWorkingSetCommand())
2929
cmd.AddCommand(createWorkingSetCommand())
3030
cmd.AddCommand(removeWorkingSetCommand())
31+
cmd.AddCommand(configWorkingSetCommand())
32+
return cmd
33+
}
34+
35+
func configWorkingSetCommand() *cobra.Command {
36+
format := string(workingset.OutputFormatHumanReadable)
37+
getAll := false
38+
var set []string
39+
var get []string
40+
var del []string
41+
42+
cmd := &cobra.Command{
43+
Use: "config <working-set-id> [--set <config-arg1> <config-arg2> ...] [--get <config-key1> <config-key2> ...] [--del <config-arg1> <config-arg2> ...]",
44+
Short: "Update the configuration of a working set",
45+
Args: cobra.MinimumNArgs(1),
46+
RunE: func(cmd *cobra.Command, args []string) error {
47+
supported := slices.Contains(workingset.SupportedFormats(), format)
48+
if !supported {
49+
return fmt.Errorf("unsupported format: %s", format)
50+
}
51+
dao, err := db.New()
52+
if err != nil {
53+
return err
54+
}
55+
ociService := oci.NewService()
56+
return workingset.UpdateConfig(cmd.Context(), dao, ociService, args[0], set, get, del, getAll, workingset.OutputFormat(format))
57+
},
58+
}
59+
60+
flags := cmd.Flags()
61+
flags.StringArrayVar(&set, "set", []string{}, "Set configuration values: <key>=<value> (can be specified multiple times)")
62+
flags.StringArrayVar(&get, "get", []string{}, "Get configuration values: <key> (can be specified multiple times)")
63+
flags.StringArrayVar(&del, "del", []string{}, "Delete configuration values: <key> (can be specified multiple times)")
64+
flags.BoolVar(&getAll, "get-all", false, "Get all configuration values")
65+
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))
66+
3167
return cmd
3268
}
3369

docs/working-sets.md

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,76 @@ docker mcp workingset rm my-working-set
123123

124124
**Note:** This only removes the working set definition, not the actual server images or registry entries.
125125

126+
### Configuring Working Set Servers
127+
128+
Manage configuration values for servers within a working set:
129+
130+
```bash
131+
# Set a single configuration value
132+
docker mcp workingset config my-working-set --set github.timeout=30
133+
134+
# Set multiple configuration values
135+
docker mcp workingset config my-working-set \
136+
--set github.timeout=30 \
137+
--set github.maxRetries=3 \
138+
--set slack.channel=general
139+
140+
# Get a specific configuration value
141+
docker mcp workingset config my-working-set --get github.timeout
142+
143+
# Get multiple configuration values
144+
docker mcp workingset config my-working-set \
145+
--get github.timeout \
146+
--get github.maxRetries
147+
148+
# Get all configuration values
149+
docker mcp workingset config my-working-set --get-all
150+
151+
# Delete configuration values
152+
docker mcp workingset config my-working-set --del github.maxRetries
153+
154+
# Combine operations (set new values and get existing ones)
155+
docker mcp workingset config my-working-set \
156+
--set github.timeout=60 \
157+
--get github.maxRetries
158+
159+
# Output in JSON format
160+
docker mcp workingset config my-working-set --get-all --format json
161+
162+
# Output in YAML format
163+
docker mcp workingset config my-working-set --get-all --format yaml
164+
```
165+
166+
**Configuration format:**
167+
- `--set`: Format is `<server-name>.<config-key>=<value>` (can be specified multiple times)
168+
- `--get`: Format is `<server-name>.<config-key>` (can be specified multiple times)
169+
- `--del`: Format is `<server-name>.<config-key>` (can be specified multiple times)
170+
- `--get-all`: Retrieves all configuration values from all servers in the working set
171+
- `--format`: Output format - `human` (default), `json`, or `yaml`
172+
173+
**Important notes:**
174+
- The server name must match the name from the server's snapshot (not the image or source URL)
175+
- Use `docker mcp workingset show <working-set-id> --format yaml` to see available server names
176+
- Configuration changes are persisted immediately to the working set
177+
- You cannot both `--set` and `--del` the same key in a single command
178+
- **Note**: Config is for non-sensitive settings. Use secrets management for API keys, tokens, and passwords.
179+
180+
### Managing Secrets for Working Set Servers
181+
182+
Secrets provide secure storage for sensitive values like API keys, tokens, and passwords. Unlike configuration values, secrets are stored securely and never displayed in plain text.
183+
184+
```bash
185+
# Set a secret for a server in a working set
186+
docker mcp secret set github.pat=ghp_xxxxx
187+
```
188+
189+
**Secret format:**
190+
- Format is `<server-name>.<secret-key>=<value>`
191+
- The server name must match the name from the server's snapshot
192+
- Secrets are stored in Docker Desktop's secure secret store
193+
194+
**Current Limitation**: Secrets are scoped across all servers rather than for each working set. We plan to address this.
195+
126196
### Exporting Working Sets
127197

128198
Export a working set to a file for backup or sharing:
@@ -407,8 +477,9 @@ docker mcp workingset pull docker.io/myorg/my-tools:1.1
407477

408478
### Security Considerations
409479

410-
- Working sets use Docker Desktop's secret store by default
411-
- Don't commit exported working sets with sensitive config to version control
480+
- Always use `docker mcp secret set` for sensitive values (API keys, tokens, passwords)
481+
- Never use `docker mcp workingset config` for secrets - it's for non-sensitive settings only
482+
- Secrets are stored in Docker Desktop's secure secret store
412483
- Use private OCI registries for proprietary server configurations
413484
- Review server references before importing from external sources
414485

@@ -467,18 +538,54 @@ Error: unsupported file extension: .txt, must be .yaml or .json
467538

468539
**Solution**: Use `.yaml` or `.json` file extensions
469540

541+
### Invalid Config Format
542+
543+
```bash
544+
Error: invalid config argument: myconfig, expected <serverName>.<configName>=<value>
545+
```
546+
547+
**Solution**: Ensure config arguments follow the correct format:
548+
- For `--set`: `<server-name>.<config-key>=<value>` (e.g., `github.timeout=30`)
549+
- For `--get`: `<server-name>.<config-key>` (e.g., `github.timeout`)
550+
- For `--del`: `<server-name>.<config-key>` (e.g., `github.timeout`)
551+
552+
### Server Not Found in Config Command
553+
554+
```bash
555+
Error: server github not found in working set
556+
```
557+
558+
**Solution**:
559+
- Use `docker mcp workingset show <working-set-id>` to see available server names
560+
- Ensure you're using the server's name from its snapshot, not the image name or source URL
561+
- Server names are case-sensitive
562+
563+
### Cannot Delete and Set Same Config
564+
565+
```bash
566+
Error: cannot both delete and set the same config value: github.timeout
567+
```
568+
569+
**Solution**: Don't use `--set` and `--del` for the same key in a single command. Run them separately:
570+
```bash
571+
# First delete
572+
docker mcp workingset config my-set --del github.timeout
573+
# Then set (if needed)
574+
docker mcp workingset config my-set --set github.timeout=60
575+
```
576+
470577
## Limitations and Future Enhancements
471578

472579
### Current Limitations
473580

474-
- Gateway support is limited to image-only servers (no config/secrets yet)
581+
- Gateway support is limited to image-only servers
475582
- No automatic watch/reload when working sets are updated
476583
- Limited to Docker Desktop's secret store for secrets
477584
- No built-in conflict resolution for duplicate server names
478585

479586
### Planned Enhancements
480587

481-
- Full config and secrets support in gateway
588+
- Full registry support in the gateway
482589
- Integration with catalog management
483590
- Search and discovery features
484591

0 commit comments

Comments
 (0)