diff --git a/cmd/docker-mcp/client/config.yml b/cmd/docker-mcp/client/config.yml index 539fbdc1..572d785f 100644 --- a/cmd/docker-mcp/client/config.yml +++ b/cmd/docker-mcp/client/config.yml @@ -7,9 +7,12 @@ system: - /Applications/Claude.app - $AppData\Claude\ paths: - linux: $HOME/.config/claude/claude_desktop_config.json - darwin: $HOME/Library/Application Support/Claude/claude_desktop_config.json - windows: $APPDATA\Claude\claude_desktop_config.json + linux: + - $HOME/.config/claude/claude_desktop_config.json + darwin: + - $HOME/Library/Application Support/Claude/claude_desktop_config.json + windows: + - $APPDATA\Claude\claude_desktop_config.json yq: list: '.mcpServers | to_entries | map(.value + {"name": .key})' set: .mcpServers[$NAME] = $JSON @@ -22,9 +25,12 @@ system: - $HOME/.continue - $USERPROFILE\.continue paths: - linux: $HOME/.continue/config.yaml - darwin: $HOME/.continue/config.yaml - windows: $USERPROFILE\.continue\config.yaml + linux: + - $HOME/.continue/config.yaml + darwin: + - $HOME/.continue/config.yaml + windows: + - $USERPROFILE\.continue\config.yaml yq: list: .mcpServers set: .mcpServers = (.mcpServers // []) | .mcpServers += [{"name":$NAME}+$JSON] @@ -37,9 +43,12 @@ system: - /Applications/Cursor.app - $AppData/Cursor/ paths: - linux: $HOME/.cursor/mcp.json - darwin: $HOME/.cursor/mcp.json - windows: $USERPROFILE\.cursor\mcp.json + linux: + - $HOME/.cursor/mcp.json + darwin: + - $HOME/.cursor/mcp.json + windows: + - $USERPROFILE\.cursor\mcp.json yq: list: '.mcpServers | to_entries | map(.value + {"name": .key})' set: .mcpServers[$NAME] = $JSON @@ -52,9 +61,12 @@ system: - $HOME/.gemini - $USERPROFILE\.gemini paths: - linux: $HOME/.gemini/settings.json - darwin: $HOME/.gemini/settings.json - windows: $USERPROFILE\.gemini\settings.json + linux: + - $HOME/.gemini/settings.json + darwin: + - $HOME/.gemini/settings.json + windows: + - $USERPROFILE\.gemini\settings.json yq: list: '.mcpServers | to_entries | map(.value + {"name": .key})' set: .mcpServers[$NAME] = $JSON @@ -67,9 +79,12 @@ system: - $HOME/.config/goose - $USERPROFILE\.config\goose paths: - linux: $HOME/.config/goose/config.yaml - darwin: $HOME/.config/goose/config.yaml - windows: $USERPROFILE\.config\goose\config.yaml + linux: + - $HOME/.config/goose/config.yaml + darwin: + - $HOME/.config/goose/config.yaml + windows: + - $USERPROFILE\.config\goose\config.yaml yq: list: '.extensions | to_entries | map(select(.value.bundled != true)) | map(.value + {"name": .key})' set: '.extensions[$SIMPLE_NAME] = { @@ -91,11 +106,19 @@ system: icon: https://raw.githubusercontent.com/docker/mcp-gateway/main/img/client/lmstudio.png installCheckPaths: - $HOME/.lmstudio + - $HOME/.cache/lm-studio - $USERPROFILE\.lmstudio + - $USERPROFILE\.cache\lm-studio paths: - linux: $HOME/.lmstudio/mcp.json - darwin: $HOME/.lmstudio/mcp.json - windows: $USERPROFILE\.lmstudio\mcp.json + linux: + - $HOME/.cache/lm-studio/mcp.json + - $HOME/.lmstudio/mcp.json + darwin: + - $HOME/.cache/lm-studio/mcp.json + - $HOME/.lmstudio/mcp.json + windows: + - $USERPROFILE\.cache\lm-studio\mcp.json + - $USERPROFILE\.lmstudio\mcp.json yq: list: '.mcpServers | to_entries | map(.value + {"name": .key})' set: .mcpServers[$NAME] = $JSON @@ -108,9 +131,12 @@ system: - $HOME/.sema4ai - $USERPROFILE\AppData\Local\sema4ai paths: - linux: $HOME/.sema4ai/sema4ai-studio/mcp_servers.json - darwin: $HOME/.sema4ai/sema4ai-studio/mcp_servers.json - windows: $USERPROFILE\AppData\Local\sema4ai\sema4ai-studio\mcp_servers.json + linux: + - $HOME/.sema4ai/sema4ai-studio/mcp_servers.json + darwin: + - $HOME/.sema4ai/sema4ai-studio/mcp_servers.json + windows: + - $USERPROFILE\AppData\Local\sema4ai\sema4ai-studio\mcp_servers.json yq: list: '.mcpServers | to_entries | map(.value + {"name": .key})' set: .mcpServers[$NAME] = $JSON+{"transport":"stdio"} diff --git a/cmd/docker-mcp/client/global.go b/cmd/docker-mcp/client/global.go index a065983c..ff6ff11b 100644 --- a/cmd/docker-mcp/client/global.go +++ b/cmd/docker-mcp/client/global.go @@ -21,12 +21,12 @@ type globalCfg struct { } type Paths struct { - Linux string `yaml:"linux"` - Darwin string `yaml:"darwin"` - Windows string `yaml:"windows"` + Linux []string `yaml:"linux"` + Darwin []string `yaml:"darwin"` + Windows []string `yaml:"windows"` } -func (c *globalCfg) GetPathsForCurrentOS() string { +func (c *globalCfg) GetPathsForCurrentOS() []string { switch runtime.GOOS { case "darwin": return c.Darwin @@ -35,7 +35,7 @@ func (c *globalCfg) GetPathsForCurrentOS() string { case "windows": return c.Windows } - return "" + return []string{} } func (c *globalCfg) isInstalled() (bool, error) { @@ -58,7 +58,13 @@ type GlobalCfgProcessor struct { } func NewGlobalCfgProcessor(g globalCfg) (*GlobalCfgProcessor, error) { - p, err := newYQProcessor(g.YQ, g.GetPathsForCurrentOS()) + paths := g.GetPathsForCurrentOS() + if len(paths) == 0 { + return nil, fmt.Errorf("no paths configured for OS %s", runtime.GOOS) + } + // All paths for a client must use same file format (json/yaml) since YQ processor + // determines encoding from first path but may operate on any path + p, err := newYQProcessor(g.YQ, paths[0]) if err != nil { return nil, err } @@ -71,52 +77,68 @@ func NewGlobalCfgProcessor(g globalCfg) (*GlobalCfgProcessor, error) { func (c *GlobalCfgProcessor) ParseConfig() MCPClientCfg { result := MCPClientCfg{MCPClientCfgBase: MCPClientCfgBase{DisplayName: c.DisplayName, Source: c.Source, Icon: c.Icon}} - path := c.GetPathsForCurrentOS() - if path == "" { + paths := c.GetPathsForCurrentOS() + if len(paths) == 0 { return result } - fullPath := os.ExpandEnv(path) result.IsOsSupported = true - data, err := os.ReadFile(fullPath) - if os.IsNotExist(err) { - // it's not an error for us, it just means nothing is configured/connected - installed, installCheckErr := c.isInstalled() - result.IsInstalled = installed - result.Err = classifyError(installCheckErr) - return result - } - - // The file was found but can't be read. Because of an old bug, it could be a directory. - // In which case, we want to delete it. - stat, err := os.Stat(fullPath) - if err == nil && stat.IsDir() { - if err := os.RemoveAll(fullPath); err != nil { - result.Err = classifyError(err) + for _, path := range paths { + fullPath := os.ExpandEnv(path) + data, err := os.ReadFile(fullPath) + if err == nil { + result.IsInstalled = true + result.setParseResult(c.p.Parse(data)) return result } - installed, installCheckErr := c.isInstalled() - result.IsInstalled = installed - result.Err = classifyError(installCheckErr) - return result - } - // config exists for us means it's installed (we then don't care if it's actually installed or not) - result.IsInstalled = true - if err != nil { + if os.IsNotExist(err) { + continue + } + + // File exists but can't be read. Because of an old bug, it could be a directory. + // In which case, we want to delete it. + stat, statErr := os.Stat(fullPath) + if statErr == nil && stat.IsDir() { + if rmErr := os.RemoveAll(fullPath); rmErr != nil { + result.Err = classifyError(rmErr) + return result + } + continue + } + + result.IsInstalled = true result.Err = classifyError(err) return result } - result.setParseResult(c.p.Parse(data)) + + // No files found - check if the application is installed + installed, installCheckErr := c.isInstalled() + result.IsInstalled = installed + result.Err = classifyError(installCheckErr) return result } func (c *GlobalCfgProcessor) Update(key string, server *MCPServerSTDIO) error { - file := c.GetPathsForCurrentOS() - if file == "" { + paths := c.GetPathsForCurrentOS() + if len(paths) == 0 { return fmt.Errorf("unknown config path for OS %s", runtime.GOOS) } - return updateConfig(os.ExpandEnv(file), c.p.Add, c.p.Del, key, server) + + // Use first existing path, or first path if none exist + var targetPath string + for _, path := range paths { + fullPath := os.ExpandEnv(path) + if _, err := os.Stat(fullPath); err == nil { + targetPath = fullPath + break + } + } + if targetPath == "" { + targetPath = os.ExpandEnv(paths[0]) + } + + return updateConfig(targetPath, c.p.Add, c.p.Del, key, server) } func containsMCPDocker(in []MCPServerSTDIO) bool { diff --git a/cmd/docker-mcp/client/global_test.go b/cmd/docker-mcp/client/global_test.go new file mode 100644 index 00000000..52bca070 --- /dev/null +++ b/cmd/docker-mcp/client/global_test.go @@ -0,0 +1,213 @@ +package client + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestGlobalCfg creates a standard globalCfg for testing +func newTestGlobalCfg() globalCfg { + return globalCfg{ + DisplayName: "Test Client", + YQ: YQ{ + List: ".mcpServers | to_entries | map(.value + {\"name\": .key})", + Set: ".mcpServers[$NAME] = $JSON", + Del: "del(.mcpServers[$NAME])", + }, + } +} + +// setPathsForCurrentOS sets the appropriate OS-specific paths field for testing +func setPathsForCurrentOS(cfg *globalCfg, paths []string) { + switch runtime.GOOS { + case "windows": + cfg.Windows = paths + case "darwin": + cfg.Darwin = paths + default: + cfg.Linux = paths + } +} + +func TestGlobalCfgProcessor_MultiplePaths(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + setupFiles map[string]string + configPaths []string + expectedFound bool + expectedError bool + }{ + { + name: "single_path_exists", + setupFiles: map[string]string{ + "config.json": `{"mcpServers": {"test": {"command": "echo"}}}`, + }, + configPaths: []string{"config.json"}, + expectedFound: true, + }, + { + name: "multiple_paths_first_exists", + setupFiles: map[string]string{ + "config1.json": `{"mcpServers": {"test": {"command": "echo"}}}`, + "config2.json": `{"mcpServers": {"other": {"command": "ls"}}}`, + }, + configPaths: []string{"config1.json", "config2.json"}, + expectedFound: true, + }, + { + name: "multiple_paths_second_exists", + setupFiles: map[string]string{ + "config2.json": `{"mcpServers": {"fallback": {"command": "fallback"}}}`, + }, + configPaths: []string{"config1.json", "config2.json"}, + expectedFound: true, + }, + { + name: "no_paths_exist", + setupFiles: map[string]string{}, + configPaths: []string{"config1.json", "config2.json"}, + expectedFound: false, + }, + { + name: "file_is_directory", + setupFiles: map[string]string{ + "config1.json/": "", // Directory instead of file + "config2.json": `{"mcpServers": {"backup": {"command": "backup"}}}`, + }, + configPaths: []string{"config1.json", "config2.json"}, + expectedFound: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + testDir := filepath.Join(tempDir, tc.name) + require.NoError(t, os.MkdirAll(testDir, 0o755)) + + var paths []string + for _, path := range tc.configPaths { + paths = append(paths, filepath.Join(testDir, path)) + } + + for path, content := range tc.setupFiles { + fullPath := filepath.Join(testDir, path) + if filepath.Ext(path) == "/" { + require.NoError(t, os.MkdirAll(fullPath, 0o755)) + } else { + require.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644)) + } + } + + cfg := newTestGlobalCfg() + setPathsForCurrentOS(&cfg, paths) + + processor, err := NewGlobalCfgProcessor(cfg) + if tc.expectedError { + assert.Error(t, err) + return + } + require.NoError(t, err) + + result := processor.ParseConfig() + + if tc.expectedFound { + assert.True(t, result.IsInstalled) + assert.Nil(t, result.Err) + assert.NotNil(t, result.cfg) + } else { + assert.False(t, result.IsInstalled) + } + }) + } +} + +func TestGlobalCfgProcessor_Update_MultiplePaths(t *testing.T) { + tempDir := t.TempDir() + + config1Path := filepath.Join(tempDir, "config1.json") + config2Path := filepath.Join(tempDir, "config2.json") + + require.NoError(t, os.WriteFile(config1Path, []byte(`{"mcpServers": {"existing": {"command": "test"}}}`), 0o644)) + + cfg := newTestGlobalCfg() + paths := []string{config1Path, config2Path} + setPathsForCurrentOS(&cfg, paths) + + processor, err := NewGlobalCfgProcessor(cfg) + require.NoError(t, err) + + err = processor.Update("new-server", &MCPServerSTDIO{ + Name: "new-server", + Command: "docker", + Args: []string{"mcp", "gateway", "run"}, + }) + require.NoError(t, err) + + content, err := os.ReadFile(config1Path) + require.NoError(t, err) + assert.Contains(t, string(content), "new-server") + + _, err = os.ReadFile(config2Path) + assert.True(t, os.IsNotExist(err)) +} + +func TestGlobalCfgProcessor_Update_NoExistingFiles(t *testing.T) { + tempDir := t.TempDir() + + config1Path := filepath.Join(tempDir, "config1.json") + config2Path := filepath.Join(tempDir, "config2.json") + + cfg := newTestGlobalCfg() + paths := []string{config1Path, config2Path} + setPathsForCurrentOS(&cfg, paths) + + processor, err := NewGlobalCfgProcessor(cfg) + require.NoError(t, err) + + err = processor.Update("new-server", &MCPServerSTDIO{ + Name: "new-server", + Command: "docker", + Args: []string{"mcp", "gateway", "run"}, + }) + require.NoError(t, err) + + content, err := os.ReadFile(config1Path) + require.NoError(t, err) + assert.Contains(t, string(content), "new-server") + + _, err = os.ReadFile(config2Path) + assert.True(t, os.IsNotExist(err)) +} + +func TestGlobalCfgProcessor_EmptyPaths(t *testing.T) { + cfg := newTestGlobalCfg() + + _, err := NewGlobalCfgProcessor(cfg) + require.Error(t, err) + assert.ErrorContains(t, err, "no paths configured for OS") +} + +func TestGlobalCfgProcessor_SinglePath(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + require.NoError(t, os.WriteFile(configPath, []byte(`{"mcpServers": {"test": {"command": "echo"}}}`), 0o644)) + + cfg := newTestGlobalCfg() + setPathsForCurrentOS(&cfg, []string{configPath}) + + processor, err := NewGlobalCfgProcessor(cfg) + require.NoError(t, err) + + result := processor.ParseConfig() + assert.True(t, result.IsInstalled) + assert.True(t, result.IsOsSupported) + assert.Nil(t, result.Err) +}