Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/sin-code/internal/mcpclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ type configFile struct {
// LoadConfigs returns the effective server list for a workspace.
// Missing files are fine; broken files are logged to stderr and skipped
// (additive, never fatal — same guarantee as ConnectAll).
// Merge order: built-in defaults -> discovered configs -> ~/.config/sin-code/mcp.json -> workspace/.sin-code/mcp.json.
func LoadConfigs(workspace string) []ServerConfig {
merged := map[string]fileEntry{}
for _, e := range DefaultServers() {
merged[e.Name] = fileEntry{ServerConfig: e}
}
for _, c := range DiscoverConfigs(workspace) {
merged[c.Name] = fileEntry{ServerConfig: c}
}
for _, path := range configPaths(workspace) {
entries, err := readConfigFile(path)
if err != nil {
Expand Down
177 changes: 177 additions & 0 deletions cmd/sin-code/internal/mcpclient/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: MIT
// Purpose: dynamic MCP server discovery from standard config locations (issue #368).
package mcpclient

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)

var (
// userConfigDirHook lets tests override the config directory without
// touching the real filesystem.
userConfigDirHook = os.UserConfigDir
)

// DiscoverConfigs scans well-known MCP server config locations and returns
// discovered ServerConfig entries. Later entries override earlier ones by name.
// The scan locations are:
// - ~/.config/mcp/servers/*.json
// - ~/.config/claude/claude_desktop_config.json (mcpServers map)
// - ~/.opencode/opencode.json (mcpServers map)
// - ~/.codex/config.json (mcpServers map)
// - <workspace>/.sin-code/mcp.json (mcpServers map)
func DiscoverConfigs(workspace string) []ServerConfig {
merged := map[string]ServerConfig{}

// Per-user directory of individual server JSON files.
if cfg, err := userConfigDirHook(); err == nil {
serversDir := filepath.Join(cfg, "mcp", "servers")
entries, _ := os.ReadDir(serversDir)
for _, e := range entries {
if e.IsDir() || !stringsHasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(serversDir, e.Name())
cfgs, err := readServerConfigs(path)
if err != nil {
fmt.Fprintf(os.Stderr, "warn: skipping discovered mcp config %s: %v\n", path, err)
continue
}
for _, c := range cfgs {
merged[c.Name] = c
}
}
}

// Claude Desktop config.
if home, err := userHomeDirHook(); err == nil {
if cfgs, err := readMCPServersMap(filepath.Join(home, ".config", "claude", "claude_desktop_config.json")); err == nil {
for _, c := range cfgs {
merged[c.Name] = c
}
}
}

// opencode config.
if home, err := userHomeDirHook(); err == nil {
if cfgs, err := readMCPServersMap(filepath.Join(home, ".config", "opencode", "opencode.json")); err == nil {
for _, c := range cfgs {
merged[c.Name] = c
}
}
}

// codex config.
if home, err := userHomeDirHook(); err == nil {
if cfgs, err := readMCPServersMap(filepath.Join(home, ".config", "codex", "config.json")); err == nil {
for _, c := range cfgs {
merged[c.Name] = c
}
}
}

// Workspace config.
if workspace != "" {
if cfgs, err := readMCPServersMap(filepath.Join(workspace, ".sin-code", "mcp.json")); err == nil {
for _, c := range cfgs {
merged[c.Name] = c
}
}
}

out := make([]ServerConfig, 0, len(merged))
for _, c := range merged {
out = append(out, c)
}
return out
}

func stringsHasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

// readServerConfigs parses a single JSON file that may be either a ServerConfig
// object or an array of ServerConfig objects.
func readServerConfigs(path string) ([]ServerConfig, error) {
data, err := os.ReadFile(path)

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
if err != nil {
return nil, err
}
var single ServerConfig
if err := json.Unmarshal(data, &single); err == nil && single.Name != "" {
return []ServerConfig{single}, nil
}
var arr []ServerConfig
if err := json.Unmarshal(data, &arr); err != nil {
return nil, fmt.Errorf("neither ServerConfig object nor array: %w", err)
}
return arr, nil
}

// mcpServersFile is the common shape used by Claude Desktop, opencode, and codex.
type mcpServersFile struct {
MCPServers map[string]struct {
Command string `json:"command"`
Args []string `json:"args"`
URL string `json:"url"`
Env map[string]string `json:"env"`
} `json:"mcpServers"`
}

// readMCPServersMap parses a config file with a top-level mcpServers map and
// returns ServerConfig entries. The map key is used as the server name if the
// entry does not already contain one.
func readMCPServersMap(path string) ([]ServerConfig, error) {
data, err := os.ReadFile(path)

Check failure

Code scanning / gosec

Potential file inclusion via variable Error

Potential file inclusion via variable
if err != nil {
return nil, err
}
var f mcpServersFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, err
}
if len(f.MCPServers) == 0 {
return nil, nil
}
out := make([]ServerConfig, 0, len(f.MCPServers))
for name, e := range f.MCPServers {
transport := "stdio"
if e.URL != "" {
transport = "sse"
}
out = append(out, ServerConfig{
Name: name,
Transport: transport,
Command: e.Command,
Args: e.Args,
URL: e.URL,
Env: e.Env,
})
}
return out, nil
}

// WriteServerConfig writes a single ServerConfig to the user config directory
// as ~/.config/mcp/servers/<name>.json.
func WriteServerConfig(cfg ServerConfig) error {
if cfg.Name == "" {
return fmt.Errorf("server config name required")
}
configDir, err := userConfigDirHook()
if err != nil {
return err
}
serversDir := filepath.Join(configDir, "mcp", "servers")
if err := os.MkdirAll(serversDir, 0o755); err != nil {

Check failure

Code scanning / gosec

Expect directory permissions to be 0750 or less Error

Expect directory permissions to be 0750 or less
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
path := filepath.Join(serversDir, cfg.Name+".json")
return os.WriteFile(path, data, 0o644)

Check failure

Code scanning / gosec

Expect WriteFile permissions to be 0600 or less Error

Expect WriteFile permissions to be 0600 or less
}
122 changes: 122 additions & 0 deletions cmd/sin-code/internal/mcpclient/discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT
package mcpclient

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

func TestDiscoverConfigs_IndividualFiles(t *testing.T) {
dir := t.TempDir()
oldHook := userConfigDirHook
userConfigDirHook = func() (string, error) { return dir, nil }
defer func() { userConfigDirHook = oldHook }()

serversDir := filepath.Join(dir, "mcp", "servers")
if err := os.MkdirAll(serversDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(serversDir, "foo.json"), []byte(`{"name":"foo","transport":"stdio","command":"echo","args":["hello"]}`), 0o644); err != nil {
t.Fatal(err)
}

cfgs := DiscoverConfigs("")
if len(cfgs) != 1 {
t.Fatalf("got %d configs, want 1", len(cfgs))
}
if cfgs[0].Name != "foo" {
t.Errorf("name = %q, want foo", cfgs[0].Name)
}
}

func TestDiscoverConfigs_MCPServersMap(t *testing.T) {
dir := t.TempDir()
oldConfigHook := userConfigDirHook
oldHomeHook := userHomeDirHook
userConfigDirHook = func() (string, error) { return dir, nil }
userHomeDirHook = func() (string, error) { return dir, nil }
defer func() {
userConfigDirHook = oldConfigHook
userHomeDirHook = oldHomeHook
}()

claudeDir := filepath.Join(dir, ".config", "claude")
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
t.Fatal(err)
}
content := []byte(`{"mcpServers":{"bar":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}}}`)
if err := os.WriteFile(filepath.Join(claudeDir, "claude_desktop_config.json"), content, 0o644); err != nil {
t.Fatal(err)
}

cfgs := DiscoverConfigs("")
if len(cfgs) != 1 {
t.Fatalf("got %d configs, want 1", len(cfgs))
}
if cfgs[0].Name != "bar" {
t.Errorf("name = %q, want bar", cfgs[0].Name)
}
if cfgs[0].Command != "npx" {
t.Errorf("command = %q, want npx", cfgs[0].Command)
}
}

func TestDiscoverConfigs_WorkspaceOverride(t *testing.T) {
dir := t.TempDir()
ws := t.TempDir()
oldHook := userConfigDirHook
userConfigDirHook = func() (string, error) { return dir, nil }
defer func() { userConfigDirHook = oldHook }()

serversDir := filepath.Join(dir, "mcp", "servers")
if err := os.MkdirAll(serversDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(serversDir, "baz.json"), []byte(`{"name":"baz","transport":"stdio","command":"echo"}`), 0o644); err != nil {
t.Fatal(err)
}

wsCfg := filepath.Join(ws, ".sin-code", "mcp.json")
if err := os.MkdirAll(filepath.Dir(wsCfg), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(wsCfg, []byte(`{"mcpServers":{"baz":{"command":"overridden","args":[]}}}`), 0o644); err != nil {
t.Fatal(err)
}

cfgs := DiscoverConfigs(ws)
if len(cfgs) != 1 {
t.Fatalf("got %d configs, want 1", len(cfgs))
}
if cfgs[0].Command != "overridden" {
t.Errorf("workspace did not override: command = %q", cfgs[0].Command)
}
}

func TestWriteServerConfig(t *testing.T) {
dir := t.TempDir()
oldHook := userConfigDirHook
userConfigDirHook = func() (string, error) { return dir, nil }
defer func() { userConfigDirHook = oldHook }()

if err := WriteServerConfig(ServerConfig{Name: "qux", Transport: "stdio", Command: "echo"}); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "mcp", "servers", "qux.json")
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file not created: %v", err)
}
}

func TestDiscoverConfigs_NoFiles(t *testing.T) {
dir := t.TempDir()
oldHook := userConfigDirHook
userConfigDirHook = func() (string, error) { return dir, nil }
defer func() { userConfigDirHook = oldHook }()

cfgs := DiscoverConfigs("")
if len(cfgs) != 0 {
t.Fatalf("got %d configs, want 0", len(cfgs))
}
}
Loading
Loading