Skip to content

Commit 1e1083d

Browse files
Refactor MCP setup (#1315)
* factor out call to runtime.GOOS so we can mock it * factor out os.UserHomeDir so we can mock it * test getClientConfigPath * refactor getClientConfigPath * update vscode config path to mcp.json * factor out handleStandardConfig * update nightly tag to "nightly" rather than "devel" (#1313) * remove top-level mcp property from mcp.json config * print []MCPClient directly with %v * gosec comments * refactor initializing MCP Client --------- Co-authored-by: Eric Liu <[email protected]>
1 parent 1402323 commit 1e1083d

File tree

2 files changed

+391
-126
lines changed

2 files changed

+391
-126
lines changed

src/pkg/mcp/setup.go

Lines changed: 179 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,9 @@ type MCPConfig struct {
2929
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
3030
}
3131

32-
// VSCodeConfig represents the VSCode settings.json structure
32+
// VSCodeConfig represents the VSCode mcp.json structure
3333
type VSCodeConfig struct {
34-
MCP struct {
35-
Servers map[string]VSCodeMCPServerConfig `json:"servers"`
36-
} `json:"mcp"`
34+
Servers map[string]VSCodeMCPServerConfig `json:"servers"`
3735
// Other VSCode settings can be preserved with this field
3836
Other map[string]interface{} `json:"-"`
3937
}
@@ -49,95 +47,135 @@ type VSCodeMCPServerConfig struct {
4947
Headers map[string]string `json:"headers,omitempty"` // For sse
5048
}
5149

50+
// MCPClient represents the supported MCP clients as an enum
51+
type MCPClient string
52+
53+
const (
54+
MCPClientVSCode MCPClient = "vscode"
55+
MCPClientCode MCPClient = "code"
56+
MCPClientVSCodeInsiders MCPClient = "vscode-insiders"
57+
MCPClientInsiders MCPClient = "insiders"
58+
MCPClientClaude MCPClient = "claude"
59+
MCPClientWindsurf MCPClient = "windsurf"
60+
MCPClientCascade MCPClient = "cascade"
61+
MCPClientCodeium MCPClient = "codeium"
62+
MCPClientCursor MCPClient = "cursor"
63+
)
64+
5265
// ValidVSCodeClients is a list of supported VSCode MCP clients with shorthand names
53-
var ValidVSCodeClients = []string{
54-
"vscode",
55-
"code",
56-
"vscode-insiders",
57-
"insiders",
66+
var ValidVSCodeClients = []MCPClient{
67+
MCPClientVSCode,
68+
MCPClientCode,
69+
MCPClientVSCodeInsiders,
70+
MCPClientInsiders,
5871
}
5972

6073
// ValidClients is a list of supported MCP clients
6174
var ValidClients = append(
62-
[]string{
63-
"claude",
64-
"windsurf",
65-
"cursor",
75+
[]MCPClient{
76+
MCPClientClaude,
77+
MCPClientWindsurf,
78+
MCPClientCascade,
79+
MCPClientCodeium,
80+
MCPClientCursor,
6681
},
6782
ValidVSCodeClients...,
6883
)
6984

70-
// isValidClient checks if the provided client is in the list of valid clients
71-
func isValidClient(client string) bool {
72-
return slices.Contains(ValidClients, client)
85+
func ParseMCPClient(clientStr string) (MCPClient, error) {
86+
clientStr = strings.ToLower(clientStr)
87+
client := MCPClient(clientStr)
88+
if !slices.Contains(ValidClients, client) {
89+
return "", fmt.Errorf("invalid MCP client: %q. Valid MCP clients are: %v", client, ValidClients)
90+
}
91+
return client, nil
7392
}
7493

75-
// getClientConfigPath returns the path to the config file for the given client
76-
func getClientConfigPath(client string) (string, error) {
77-
homeDir, err := os.UserHomeDir()
78-
if err != nil {
79-
return "", fmt.Errorf("failed to get home directory: %w", err)
80-
}
94+
// ClientInfo defines where each client stores its MCP configuration
95+
type ClientInfo struct {
96+
configFile string // Configuration file name
97+
useHomeDir bool // True if config goes directly in home dir, false if in system config dir
98+
}
8199

82-
var configPath string
83-
switch strings.ToLower(client) {
84-
case "windsurf", "cascade", "codeium":
85-
configPath = filepath.Join(homeDir, ".codeium", "windsurf", "mcp_config.json")
86-
case "claude":
87-
if runtime.GOOS == "darwin" {
88-
configPath = filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json")
89-
} else if runtime.GOOS == "windows" {
90-
appData := os.Getenv("APPDATA")
91-
if appData == "" {
92-
appData = filepath.Join(homeDir, "AppData", "Roaming")
93-
}
94-
configPath = filepath.Join(appData, "Claude", "claude_desktop_config.json")
95-
} else {
96-
configHome := os.Getenv("XDG_CONFIG_HOME")
97-
if configHome == "" {
98-
configHome = filepath.Join(homeDir, ".config")
99-
}
100-
configPath = filepath.Join(configHome, "Claude", "claude_desktop_config.json")
101-
}
102-
case "cursor":
103-
configPath = filepath.Join(homeDir, ".cursor", "mcp.json")
104-
case "vscode", "code":
105-
if runtime.GOOS == "darwin" {
106-
configPath = filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "settings.json")
107-
} else if runtime.GOOS == "windows" {
108-
appData := os.Getenv("APPDATA")
109-
if appData == "" {
110-
appData = filepath.Join(homeDir, "AppData", "Roaming")
111-
}
112-
configPath = filepath.Join(appData, "Code", "User", "settings.json")
113-
} else {
114-
configHome := os.Getenv("XDG_CONFIG_HOME")
115-
if configHome == "" {
116-
configHome = filepath.Join(homeDir, ".config")
117-
}
118-
configPath = filepath.Join(configHome, "Code/User/settings.json")
100+
var windsurfConfig = ClientInfo{
101+
configFile: ".codeium/windsurf/mcp_config.json",
102+
useHomeDir: true,
103+
}
104+
105+
var vscodeConfig = ClientInfo{
106+
configFile: "Code/User/mcp.json",
107+
useHomeDir: false,
108+
}
109+
110+
var codeInsidersConfig = ClientInfo{
111+
configFile: "Code - Insiders/User/mcp.json",
112+
useHomeDir: false,
113+
}
114+
115+
var claudeConfig = ClientInfo{
116+
configFile: "Claude/claude_desktop_config.json",
117+
useHomeDir: false,
118+
}
119+
120+
var cursorConfig = ClientInfo{
121+
configFile: ".cursor/settings.json",
122+
useHomeDir: true,
123+
}
124+
125+
// clientRegistry maps client names to their configuration details
126+
var clientRegistry = map[MCPClient]ClientInfo{
127+
MCPClientWindsurf: windsurfConfig,
128+
MCPClientCascade: windsurfConfig,
129+
MCPClientCodeium: windsurfConfig,
130+
MCPClientVSCode: vscodeConfig,
131+
MCPClientCode: vscodeConfig,
132+
MCPClientVSCodeInsiders: codeInsidersConfig,
133+
MCPClientInsiders: codeInsidersConfig,
134+
MCPClientClaude: claudeConfig,
135+
MCPClientCursor: cursorConfig,
136+
}
137+
138+
// getSystemConfigDir returns the system configuration directory for the given OS
139+
func getSystemConfigDir(homeDir, goos string) string {
140+
switch goos {
141+
case "darwin":
142+
return filepath.Join(homeDir, "Library", "Application Support")
143+
case "windows":
144+
if appData := os.Getenv("APPDATA"); appData != "" {
145+
return appData
119146
}
120-
case "vscode-insiders", "insiders":
121-
if runtime.GOOS == "darwin" {
122-
configPath = filepath.Join(homeDir, "Library", "Application Support", "Code - Insiders", "User", "settings.json")
123-
} else if runtime.GOOS == "windows" {
124-
appData := os.Getenv("APPDATA")
125-
if appData == "" {
126-
appData = filepath.Join(homeDir, "AppData", "Roaming")
127-
}
128-
configPath = filepath.Join(appData, "Code - Insiders", "User", "settings.json")
129-
} else {
130-
configHome := os.Getenv("XDG_CONFIG_HOME")
131-
if configHome == "" {
132-
configHome = filepath.Join(homeDir, ".config")
133-
}
134-
configPath = filepath.Join(configHome, "Code - Insiders/User/settings.json")
147+
return filepath.Join(homeDir, "AppData", "Roaming")
148+
case "linux":
149+
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
150+
return configHome
135151
}
152+
return filepath.Join(homeDir, ".config")
136153
default:
154+
// Default to Linux behavior
155+
if configHome := os.Getenv("XDG_CONFIG_HOME"); configHome != "" {
156+
return configHome
157+
}
158+
return filepath.Join(homeDir, ".config")
159+
}
160+
}
161+
162+
// getClientConfigPath returns the path to the config file for the given client
163+
func getClientConfigPath(homeDir, goos string, client MCPClient) (string, error) {
164+
clientInfo, exists := clientRegistry[client]
165+
if !exists {
137166
return "", fmt.Errorf("unsupported client: %s", client)
138167
}
139168

140-
return configPath, nil
169+
var basePath string
170+
if clientInfo.useHomeDir {
171+
// Config goes directly in home directory
172+
basePath = homeDir
173+
} else {
174+
// Config goes in system-specific config directory
175+
basePath = getSystemConfigDir(homeDir, goos)
176+
}
177+
178+
return filepath.Join(basePath, clientInfo.configFile), nil
141179
}
142180

143181
// getDefangMCPConfig returns the default MCP config for Defang
@@ -179,7 +217,7 @@ func getVSCodeServerConfig() (map[string]interface{}, error) {
179217
}, nil
180218
}
181219

182-
// handleVSCodeConfig handles the special case for VSCode settings.json
220+
// handleVSCodeConfig handles the special case for VSCode mcp.json
183221
func handleVSCodeConfig(configPath string) error {
184222
// Create or update the config file
185223
var existingData map[string]interface{}
@@ -254,23 +292,80 @@ func handleVSCodeConfig(configPath string) error {
254292
return fmt.Errorf("failed to marshal config: %w", err)
255293
}
256294

295+
// #nosec G306 - config file does not contain sensitive data
296+
if err := os.WriteFile(configPath, data, 0644); err != nil {
297+
return fmt.Errorf("failed to write config file: %w", err)
298+
}
299+
300+
return nil
301+
}
302+
303+
func handleStandardConfig(configPath string) error {
304+
// For all other clients, use the standard format
305+
var config MCPConfig
306+
307+
// Check if the file exists
308+
if _, err := os.Stat(configPath); err == nil {
309+
// File exists, read it
310+
data, err := os.ReadFile(configPath)
311+
if err != nil {
312+
return fmt.Errorf("failed to read config file: %w", err)
313+
}
314+
315+
// Parse the JSON
316+
if err := json.Unmarshal(data, &config); err != nil {
317+
// If we can't parse it, start fresh
318+
config = MCPConfig{
319+
MCPServers: make(map[string]MCPServerConfig),
320+
}
321+
}
322+
} else {
323+
// File doesn't exist, create a new config
324+
config = MCPConfig{
325+
MCPServers: make(map[string]MCPServerConfig),
326+
}
327+
}
328+
329+
if config.MCPServers == nil {
330+
config.MCPServers = make(map[string]MCPServerConfig)
331+
}
332+
333+
defangConfig, err := getDefangMCPConfig()
334+
if err != nil {
335+
return fmt.Errorf("failed to get Defang MCP config: %w", err)
336+
}
337+
// Add or update the Defang MCP server config
338+
config.MCPServers["defang"] = *defangConfig
339+
340+
// Write the config to the file
341+
data, err := json.MarshalIndent(config, "", " ")
342+
if err != nil {
343+
return fmt.Errorf("failed to marshal config: %w", err)
344+
}
345+
346+
// #nosec G306 - config file does not contain sensitive data
257347
if err := os.WriteFile(configPath, data, 0644); err != nil {
258348
return fmt.Errorf("failed to write config file: %w", err)
259349
}
260350

261351
return nil
262352
}
263353

264-
func SetupClient(client string) error {
265-
// Validate client
266-
if !isValidClient(client) {
267-
return fmt.Errorf("invalid MCP client: %q. Valid MCP clients are: %v", client, strings.Join(ValidClients, ", "))
354+
func SetupClient(clientValue string) error {
355+
client, err := ParseMCPClient(clientValue)
356+
if err != nil {
357+
// cast the client string to MCPClient
358+
return fmt.Errorf("invalid MCP client: %q. Valid MCP clients are: %v", client, ValidClients)
268359
}
269360

270361
track.Evt("MCP Setup Client: ", track.P("client", client))
271362

363+
homeDir, err := os.UserHomeDir()
364+
if err != nil {
365+
return fmt.Errorf("failed to get home directory: %w", err)
366+
}
272367
// Get the config path for the client
273-
configPath, err := getClientConfigPath(client)
368+
configPath, err := getClientConfigPath(homeDir, runtime.GOOS, client)
274369
if err != nil {
275370
return err
276371
}
@@ -283,56 +378,14 @@ func SetupClient(client string) error {
283378
return fmt.Errorf("failed to create config directory: %w", err)
284379
}
285380

286-
// Handle VSCode settings.json specially
381+
// Handle VSCode mcp.json specially
287382
if slices.Contains(ValidVSCodeClients, client) {
288383
if err := handleVSCodeConfig(configPath); err != nil {
289384
return err
290385
}
291386
} else {
292-
// For all other clients, use the standard format
293-
var config MCPConfig
294-
295-
// Check if the file exists
296-
if _, err := os.Stat(configPath); err == nil {
297-
// File exists, read it
298-
data, err := os.ReadFile(configPath)
299-
if err != nil {
300-
return fmt.Errorf("failed to read config file: %w", err)
301-
}
302-
303-
// Parse the JSON
304-
if err := json.Unmarshal(data, &config); err != nil {
305-
// If we can't parse it, start fresh
306-
config = MCPConfig{
307-
MCPServers: make(map[string]MCPServerConfig),
308-
}
309-
}
310-
} else {
311-
// File doesn't exist, create a new config
312-
config = MCPConfig{
313-
MCPServers: make(map[string]MCPServerConfig),
314-
}
315-
}
316-
317-
if config.MCPServers == nil {
318-
config.MCPServers = make(map[string]MCPServerConfig)
319-
}
320-
321-
defangConfig, err := getDefangMCPConfig()
322-
if err != nil {
323-
return fmt.Errorf("failed to get Defang MCP config: %w", err)
324-
}
325-
// Add or update the Defang MCP server config
326-
config.MCPServers["defang"] = *defangConfig
327-
328-
// Write the config to the file
329-
data, err := json.MarshalIndent(config, "", " ")
330-
if err != nil {
331-
return fmt.Errorf("failed to marshal config: %w", err)
332-
}
333-
334-
if err := os.WriteFile(configPath, data, 0644); err != nil {
335-
return fmt.Errorf("failed to write config file: %w", err)
387+
if err := handleStandardConfig(configPath); err != nil {
388+
return err
336389
}
337390
}
338391

0 commit comments

Comments
 (0)