diff --git a/cmd/sin-code/internal/mcpclient/config.go b/cmd/sin-code/internal/mcpclient/config.go index c7cdf5d1..1e95e1f3 100644 --- a/cmd/sin-code/internal/mcpclient/config.go +++ b/cmd/sin-code/internal/mcpclient/config.go @@ -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 { diff --git a/cmd/sin-code/internal/mcpclient/discovery.go b/cmd/sin-code/internal/mcpclient/discovery.go new file mode 100644 index 00000000..74866504 --- /dev/null +++ b/cmd/sin-code/internal/mcpclient/discovery.go @@ -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) +// - /.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) + 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) + 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/.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 { + 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) +} diff --git a/cmd/sin-code/internal/mcpclient/discovery_test.go b/cmd/sin-code/internal/mcpclient/discovery_test.go new file mode 100644 index 00000000..f3276e5d --- /dev/null +++ b/cmd/sin-code/internal/mcpclient/discovery_test.go @@ -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)) + } +} diff --git a/cmd/sin-code/mcp_cmd.go b/cmd/sin-code/mcp_cmd.go index 30751496..9c69c2ce 100644 --- a/cmd/sin-code/mcp_cmd.go +++ b/cmd/sin-code/mcp_cmd.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "time" "github.com/spf13/cobra" @@ -46,6 +47,86 @@ func NewMCPCmd() *cobra.Command { var jsonOut bool var timeout time.Duration + discoverCmd := &cobra.Command{ + Use: "discover", + Short: "List MCP servers discovered from standard config locations (issue #368)", + RunE: func(cmd *cobra.Command, args []string) error { + ws, err := mcpHookVars.getwd() + if err != nil { + return err + } + cfgs := mcpclient.DiscoverConfigs(ws) + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(cfgs) + } + fmt.Fprintf(cmd.OutOrStdout(), "%-16s %-8s %s\n", "NAME", "TYPE", "TARGET") + for _, c := range cfgs { + target := c.URL + if c.Transport == "stdio" { + target = c.Command + for _, a := range c.Args { + target += " " + a + } + } + fmt.Fprintf(cmd.OutOrStdout(), "%-16s %-8s %s\n", c.Name, c.Transport, target) + } + return nil + }, + } + discoverCmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON") + + addCmd := &cobra.Command{ + Use: "add ", + Short: "Add an MCP server config to ~/.config/mcp/servers/.json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var ( + command string + url string + argsVal []string + env map[string]string + ) + command, _ = cmd.Flags().GetString("command") + url, _ = cmd.Flags().GetString("url") + if cmd.Flags().Changed("args") { + argsVal, _ = cmd.Flags().GetStringArray("args") + } + if cmd.Flags().Changed("env") { + envList, _ := cmd.Flags().GetStringArray("env") + env = map[string]string{} + for _, e := range envList { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + env[parts[0]] = parts[1] + } + } + } + transport := "stdio" + if url != "" { + transport = "sse" + } + cfg := mcpclient.ServerConfig{ + Name: args[0], + Transport: transport, + Command: command, + Args: argsVal, + URL: url, + Env: env, + } + if err := mcpclient.WriteServerConfig(cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "added MCP server %s\n", cfg.Name) + return nil + }, + } + addCmd.Flags().String("command", "", "stdio command to run") + addCmd.Flags().String("url", "", "SSE URL endpoint") + addCmd.Flags().StringArray("args", nil, "command arguments (repeatable)") + addCmd.Flags().StringArray("env", nil, "environment variables KEY=VALUE (repeatable)") + listCmd := &cobra.Command{ Use: "list", Short: "List effective server configs (defaults + user + workspace merge)", @@ -157,6 +238,6 @@ func NewMCPCmd() *cobra.Command { } callCmd.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "total timeout") - cmd.AddCommand(listCmd, statusCmd, callCmd) + cmd.AddCommand(listCmd, statusCmd, callCmd, discoverCmd, addCmd) return cmd }