Skip to content
5 changes: 4 additions & 1 deletion cmd/docker-mcp/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ type MCPClientCfgBase struct {
Icon string `json:"icon"`
ConfigName string `json:"configName"`
IsMCPCatalogConnected bool `json:"dockerMCPCatalogConnected"`
WorkingSet string `json:"workingset"`
Err *CfgError `json:"error"`

cfg *MCPJSONLists
Expand All @@ -149,8 +150,10 @@ type MCPClientCfgBase struct {
func (c *MCPClientCfgBase) setParseResult(lists *MCPJSONLists, err error) {
c.Err = classifyError(err)
if lists != nil {
if containsMCPDocker(lists.STDIOServers) {
server := containsMCPDocker(lists.STDIOServers)
if server.Name != "" {
c.IsMCPCatalogConnected = true
c.WorkingSet = server.GetWorkingSet()
}
}
c.cfg = lists
Expand Down
6 changes: 3 additions & 3 deletions cmd/docker-mcp/client/global.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,13 @@ func (c *GlobalCfgProcessor) Update(key string, server *MCPServerSTDIO) error {
return updateConfig(targetPath, c.p.Add, c.p.Del, key, server)
}

func containsMCPDocker(in []MCPServerSTDIO) bool {
func containsMCPDocker(in []MCPServerSTDIO) MCPServerSTDIO {
for _, server := range in {
if server.Name == DockerMCPCatalog || server.Name == makeSimpleName(DockerMCPCatalog) {
return true
return server
}
}
return false
return MCPServerSTDIO{}
}

type (
Expand Down
34 changes: 34 additions & 0 deletions cmd/docker-mcp/client/global_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,37 @@ func TestGlobalCfgProcessor_SinglePath(t *testing.T) {
assert.True(t, result.IsOsSupported)
assert.Nil(t, result.Err)
}

func TestGlobalCfgProcessor_SingleWorkingSet(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")

require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run", "--working-set", "test-set"]}}}`), 0o644))

cfg := newTestGlobalCfg()
setPathsForCurrentOS(&cfg, []string{configPath})

processor, err := NewGlobalCfgProcessor(cfg)
require.NoError(t, err)

result := processor.ParseConfig()
assert.True(t, result.IsMCPCatalogConnected)
assert.Equal(t, "test-set", result.WorkingSet)
}

func TestGlobalCfgProcessor_NoWorkingSet(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")

require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run"]}}}`), 0o644))

cfg := newTestGlobalCfg()
setPathsForCurrentOS(&cfg, []string{configPath})

processor, err := NewGlobalCfgProcessor(cfg)
require.NoError(t, err)

result := processor.ParseConfig()
assert.True(t, result.IsMCPCatalogConnected)
assert.Empty(t, result.WorkingSet)
}
90 changes: 90 additions & 0 deletions cmd/docker-mcp/client/local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package client

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLocalCfgProcessor_SingleWorkingSet(t *testing.T) {
tempDir := t.TempDir()
projectRoot := tempDir
projectFile := ".cursor/mcp.json"
configPath := filepath.Join(projectRoot, projectFile)

require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run", "--working-set", "project-ws"]}}}`), 0o644))

cfg := localCfg{
DisplayName: "Test Client",
ProjectFile: projectFile,
YQ: YQ{
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
Set: ".mcpServers[$NAME] = $JSON",
Del: "del(.mcpServers[$NAME])",
},
}

processor, err := NewLocalCfgProcessor(cfg, projectRoot)
require.NoError(t, err)

result := processor.Parse()
assert.True(t, result.IsConfigured)
assert.True(t, result.IsMCPCatalogConnected)
assert.Equal(t, "project-ws", result.WorkingSet)
}

func TestLocalCfgProcessor_NoWorkingSet(t *testing.T) {
tempDir := t.TempDir()
projectRoot := tempDir
projectFile := ".cursor/mcp.json"
configPath := filepath.Join(projectRoot, projectFile)

require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"MCP_DOCKER": {"command": "docker", "args": ["mcp", "gateway", "run"]}}}`), 0o644))

cfg := localCfg{
DisplayName: "Test Client",
ProjectFile: projectFile,
YQ: YQ{
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
Set: ".mcpServers[$NAME] = $JSON",
Del: "del(.mcpServers[$NAME])",
},
}

processor, err := NewLocalCfgProcessor(cfg, projectRoot)
require.NoError(t, err)

result := processor.Parse()
assert.True(t, result.IsConfigured)
assert.True(t, result.IsMCPCatalogConnected)
assert.Empty(t, result.WorkingSet)
}

func TestLocalCfgProcessor_NotConfigured(t *testing.T) {
tempDir := t.TempDir()
projectRoot := tempDir
projectFile := ".cursor/mcp.json"

cfg := localCfg{
DisplayName: "Test Client",
ProjectFile: projectFile,
YQ: YQ{
List: ".mcpServers | to_entries | map(.value + {\"name\": .key})",
Set: ".mcpServers[$NAME] = $JSON",
Del: "del(.mcpServers[$NAME])",
},
}

processor, err := NewLocalCfgProcessor(cfg, projectRoot)
require.NoError(t, err)

result := processor.Parse()
assert.False(t, result.IsConfigured)
assert.False(t, result.IsMCPCatalogConnected)
assert.Empty(t, result.WorkingSet)
}
12 changes: 12 additions & 0 deletions cmd/docker-mcp/client/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ func (c *MCPServerSTDIO) String() string {
return result
}

func (c *MCPServerSTDIO) GetWorkingSet() string {
for i := range len(c.Args) {
arg := c.Args[i]
if arg == "--working-set" || arg == "-w" {
if i+1 < len(c.Args) {
return c.Args[i+1]
}
}
}
return ""
}

type MCPServerSSE struct {
Name string `json:"name"`
URL string `json:"url"`
Expand Down
36 changes: 36 additions & 0 deletions cmd/docker-mcp/commands/workingset.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,42 @@ func workingSetCommand() *cobra.Command {
cmd.AddCommand(pullWorkingSetCommand())
cmd.AddCommand(createWorkingSetCommand())
cmd.AddCommand(removeWorkingSetCommand())
cmd.AddCommand(configWorkingSetCommand())
return cmd
}

func configWorkingSetCommand() *cobra.Command {
format := string(workingset.OutputFormatHumanReadable)
getAll := false
var set []string
var get []string
var del []string

cmd := &cobra.Command{
Use: "config <working-set-id> [--set <config-arg1> <config-arg2> ...] [--get <config-key1> <config-key2> ...] [--del <config-arg1> <config-arg2> ...]",
Short: "Update the configuration of a working set",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
supported := slices.Contains(workingset.SupportedFormats(), format)
if !supported {
return fmt.Errorf("unsupported format: %s", format)
}
dao, err := db.New()
if err != nil {
return err
}
ociService := oci.NewService()
return workingset.UpdateConfig(cmd.Context(), dao, ociService, args[0], set, get, del, getAll, workingset.OutputFormat(format))
},
}

flags := cmd.Flags()
flags.StringArrayVar(&set, "set", []string{}, "Set configuration values: <key>=<value> (can be specified multiple times)")
flags.StringArrayVar(&get, "get", []string{}, "Get configuration values: <key> (can be specified multiple times)")
flags.StringArrayVar(&del, "del", []string{}, "Delete configuration values: <key> (can be specified multiple times)")
flags.BoolVar(&getAll, "get-all", false, "Get all configuration values")
flags.StringVar(&format, "format", string(workingset.OutputFormatHumanReadable), fmt.Sprintf("Supported: %s.", strings.Join(workingset.SupportedFormats(), ", ")))

return cmd
}

Expand Down
115 changes: 111 additions & 4 deletions docs/working-sets.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,76 @@ docker mcp workingset rm my-working-set

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

### Configuring Working Set Servers

Manage configuration values for servers within a working set:

```bash
# Set a single configuration value
docker mcp workingset config my-working-set --set github.timeout=30

# Set multiple configuration values
docker mcp workingset config my-working-set \
--set github.timeout=30 \
--set github.maxRetries=3 \
--set slack.channel=general

# Get a specific configuration value
docker mcp workingset config my-working-set --get github.timeout

# Get multiple configuration values
docker mcp workingset config my-working-set \
--get github.timeout \
--get github.maxRetries

# Get all configuration values
docker mcp workingset config my-working-set --get-all

# Delete configuration values
docker mcp workingset config my-working-set --del github.maxRetries

# Combine operations (set new values and get existing ones)
docker mcp workingset config my-working-set \
--set github.timeout=60 \
--get github.maxRetries

# Output in JSON format
docker mcp workingset config my-working-set --get-all --format json

# Output in YAML format
docker mcp workingset config my-working-set --get-all --format yaml
```

**Configuration format:**
- `--set`: Format is `<server-name>.<config-key>=<value>` (can be specified multiple times)
- `--get`: Format is `<server-name>.<config-key>` (can be specified multiple times)
- `--del`: Format is `<server-name>.<config-key>` (can be specified multiple times)
- `--get-all`: Retrieves all configuration values from all servers in the working set
- `--format`: Output format - `human` (default), `json`, or `yaml`

**Important notes:**
- The server name must match the name from the server's snapshot (not the image or source URL)
- Use `docker mcp workingset show <working-set-id> --format yaml` to see available server names
- Configuration changes are persisted immediately to the working set
- You cannot both `--set` and `--del` the same key in a single command
- **Note**: Config is for non-sensitive settings. Use secrets management for API keys, tokens, and passwords.

### Managing Secrets for Working Set Servers

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.

```bash
# Set a secret for a server in a working set
docker mcp secret set github.pat=ghp_xxxxx
```

**Secret format:**
- Format is `<server-name>.<secret-key>=<value>`
- The server name must match the name from the server's snapshot
- Secrets are stored in Docker Desktop's secure secret store

**Current Limitation**: Secrets are scoped across all servers rather than for each working set. We plan to address this.

### Exporting Working Sets

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

### Security Considerations

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

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

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

### Invalid Config Format

```bash
Error: invalid config argument: myconfig, expected <serverName>.<configName>=<value>
```

**Solution**: Ensure config arguments follow the correct format:
- For `--set`: `<server-name>.<config-key>=<value>` (e.g., `github.timeout=30`)
- For `--get`: `<server-name>.<config-key>` (e.g., `github.timeout`)
- For `--del`: `<server-name>.<config-key>` (e.g., `github.timeout`)

### Server Not Found in Config Command

```bash
Error: server github not found in working set
```

**Solution**:
- Use `docker mcp workingset show <working-set-id>` to see available server names
- Ensure you're using the server's name from its snapshot, not the image name or source URL
- Server names are case-sensitive

### Cannot Delete and Set Same Config

```bash
Error: cannot both delete and set the same config value: github.timeout
```

**Solution**: Don't use `--set` and `--del` for the same key in a single command. Run them separately:
```bash
# First delete
docker mcp workingset config my-set --del github.timeout
# Then set (if needed)
docker mcp workingset config my-set --set github.timeout=60
```

## Limitations and Future Enhancements

### Current Limitations

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

### Planned Enhancements

- Full config and secrets support in gateway
- Full registry support in the gateway
- Integration with catalog management
- Search and discovery features

Expand Down
Loading
Loading