diff --git a/internal/adapter/common/io.go b/internal/adapter/common/io.go index e4022d8..8481fa1 100644 --- a/internal/adapter/common/io.go +++ b/internal/adapter/common/io.go @@ -56,33 +56,100 @@ func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) { } // ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be -// raw JSON or JSONC (comments are stripped first). +// raw JSON or JSONC (comments are stripped first). Tolerates the dialect +// differences across harnesses: +// - claude / crush: command is a string, transport key is "type" +// - kilo / opencode: command is a []string (first element is the binary) +// - some configs: env appears as "env", others as "environment" +// - some configs: transport appears as "type", others as "transport" func ParseMCPFromJSON(body []byte, key string) ([]canonical.MCPServer, error) { - clean := StripJSONComments(string(body)) + // Try plain JSON first — StripJSONComments eats // inside URL strings. var raw map[string]json.RawMessage - if err := json.Unmarshal([]byte(clean), &raw); err != nil { - return nil, nil // tolerate malformed config + if err := json.Unmarshal(body, &raw); err != nil { + clean := StripJSONComments(string(body)) + if err := json.Unmarshal([]byte(clean), &raw); err != nil { + return nil, nil + } } nested, ok := raw[key] if !ok { return nil, nil } - var entries map[string]struct { //nolint:govet // fieldalignment: map+slice+3 strings irreducible - Env map[string]string `json:"env"` - Args []string `json:"args"` - Command string `json:"command"` - URL string `json:"url"` - Transport string `json:"transport"` - } + var entries map[string]json.RawMessage if err := json.Unmarshal(nested, &entries); err != nil { return nil, nil } out := make([]canonical.MCPServer, 0, len(entries)) - for n, v := range entries { - out = append(out, canonical.MCPServer{ - Name: n, Command: v.Command, Args: v.Args, URL: v.URL, - Transport: v.Transport, Env: v.Env, - }) + for n, raw := range entries { + srv, ok := parseMCPEntry(n, raw) + if !ok { + continue + } + // Skip extension-managed entries: they have no command and no url + // (e.g. zed extensions providing their own MCP). Nothing to propagate. + if srv.Command == "" && srv.URL == "" { + continue + } + out = append(out, srv) } return out, nil } + +func parseMCPEntry(name string, raw json.RawMessage) (canonical.MCPServer, bool) { + var e struct { //nolint:govet // fieldalignment: dialect-tolerant entry, irreducible + Type string `json:"type"` + Transport string `json:"transport"` + Command json.RawMessage `json:"command"` + Cmd json.RawMessage `json:"cmd"` + Args []string `json:"args"` + URL string `json:"url"` + Env map[string]string `json:"env"` + Environment map[string]string `json:"environment"` + } + if err := json.Unmarshal(raw, &e); err != nil { + return canonical.MCPServer{}, false + } + + cmdRaw := e.Command + if len(cmdRaw) == 0 { + cmdRaw = e.Cmd + } + cmd, args := splitCommand(cmdRaw) + args = append(args, e.Args...) + + env := e.Env + if len(env) == 0 { + env = e.Environment + } + + transport := e.Type + if transport == "" { + transport = e.Transport + } + + return canonical.MCPServer{ + Name: name, + Command: cmd, + Args: args, + URL: e.URL, + Transport: transport, + Env: env, + }, true +} + +// splitCommand accepts a JSON value that may be a string ("foo") or a +// string array (["foo","--bar"]). Returns (head, tail). +func splitCommand(raw json.RawMessage) (string, []string) { + if len(raw) == 0 { + return "", nil + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, nil + } + var arr []string + if err := json.Unmarshal(raw, &arr); err == nil && len(arr) > 0 { + return arr[0], arr[1:] + } + return "", nil +} diff --git a/internal/adapter/common/mcp.go b/internal/adapter/common/mcp.go index e02ff1e..3d13873 100644 --- a/internal/adapter/common/mcp.go +++ b/internal/adapter/common/mcp.go @@ -32,20 +32,29 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any { e := map[string]any{} switch style { case MCPClaudeStyle: - if s.Command != "" { - e["command"] = s.Command - } - if len(s.Args) > 0 { - e["args"] = s.Args - } + // Claude Code's actual entry shape uses "type" (not "transport"). + // Default to stdio when no URL is set. + t := s.Transport if s.URL != "" { + if t == "" { + t = "http" + } + e["type"] = t e["url"] = s.URL - } - if s.Transport != "" { - e["transport"] = s.Transport - } - if len(s.Env) > 0 { - e["env"] = s.Env + } else { + if t == "" { + t = "stdio" + } + e["type"] = t + if s.Command != "" { + e["command"] = s.Command + } + if len(s.Args) > 0 { + e["args"] = s.Args + } + if len(s.Env) > 0 { + e["env"] = s.Env + } } case MCPCrushStyle: if s.URL != "" { diff --git a/internal/adapter/common/mcp_test.go b/internal/adapter/common/mcp_test.go index 2a4beb5..0ac2429 100644 --- a/internal/adapter/common/mcp_test.go +++ b/internal/adapter/common/mcp_test.go @@ -14,14 +14,15 @@ func reg(servers ...canonical.MCPServer) *canonical.MCPRegistry { } func TestBuildMCPMapStyledClaudeStyle(t *testing.T) { - r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"--flag"}, Transport: "stdio"}) + r := reg(canonical.MCPServer{Name: "fp", Command: "/bin/fp", Args: []string{"--flag"}}) out := BuildMCPMapStyled(r, MCPClaudeStyle) require.Contains(t, out, "fp") e := out["fp"].(map[string]any) assert.Equal(t, "/bin/fp", e["command"]) assert.Equal(t, []string{"--flag"}, e["args"]) - assert.Equal(t, "stdio", e["transport"]) - assert.NotContains(t, e, "type") + // Claude's actual entry shape uses "type" (defaults to stdio when no URL). + assert.Equal(t, "stdio", e["type"]) + assert.NotContains(t, e, "transport") } func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) { @@ -29,7 +30,7 @@ func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) { out := BuildMCPMapStyled(r, MCPClaudeStyle) e := out["remote"].(map[string]any) assert.Equal(t, "https://example.com", e["url"]) - assert.NotContains(t, e, "type") + assert.Equal(t, "http", e["type"]) } func TestBuildMCPMapStyledCrushStdio(t *testing.T) { diff --git a/internal/adapters/claudecode/claudecode.go b/internal/adapters/claudecode/claudecode.go index c225777..76869f7 100644 --- a/internal/adapters/claudecode/claudecode.go +++ b/internal/adapters/claudecode/claudecode.go @@ -78,21 +78,41 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) { Content: []byte(instructions), }) - settingsPath := filepath.Join(base, "settings.json") - existing, _ := os.ReadFile(settingsPath) - overlay := map[string]any{ - "mcpServers": common.BuildMCPMapStyled(&b.MCP, common.MCPClaudeStyle), - } - merged, err := common.MergeJSONKeys(existing, overlay) + // Claude Code reads MCP servers from two files on disk: + // * ~/.claude.json (live, written by `claude mcp add`) + // * ~/.claude/mcp_servers.json (older / fallback location) + // Write the dedicated file as the canonical destination, AND merge the + // same map into ~/.claude.json (preserving every other key in that + // large state file) so both stay in sync. + mcpMap := common.BuildMCPMapStyled(&b.MCP, common.MCPClaudeStyle) + overlay := map[string]any{"mcpServers": mcpMap} + + dedicatedPath := filepath.Join(base, "mcp_servers.json") + dedicatedExisting, _ := os.ReadFile(dedicatedPath) + dedicatedMerged, err := common.MergeJSONKeys(dedicatedExisting, overlay) if err != nil { return nil, err } fs.Add(adapter.File{ - Dest: settingsPath, + Dest: dedicatedPath, Kind: adapter.RenderedFile, - Content: merged, + Content: dedicatedMerged, }) + livePath := filepath.Join(a.home, ".claude.json") + if _, err := os.Stat(livePath); err == nil { + liveExisting, _ := os.ReadFile(livePath) + liveMerged, err := common.MergeJSONKeys(liveExisting, overlay) + if err != nil { + return nil, err + } + fs.Add(adapter.File{ + Dest: livePath, + Kind: adapter.RenderedFile, + Content: liveMerged, + }) + } + return fs, nil } diff --git a/internal/adapters/claudecode/claudecode_test.go b/internal/adapters/claudecode/claudecode_test.go index 31c15de..16b7828 100644 --- a/internal/adapters/claudecode/claudecode_test.go +++ b/internal/adapters/claudecode/claudecode_test.go @@ -42,25 +42,28 @@ func TestRenderProducesExpectedTargets(t *testing.T) { assert.Equal(t, adapter.RenderedFile, seen[claudemdDest].Kind) assert.Contains(t, string(seen[claudemdDest].Content), "# global") - settingsDest := filepath.Join(home, ".claude", "settings.json") - assert.Equal(t, adapter.RenderedFile, seen[settingsDest].Kind) - settingsBody := string(seen[settingsDest].Content) - assert.Contains(t, settingsBody, "filepuff") - assert.Contains(t, settingsBody, "/bin/filepuff") + mcpDest := filepath.Join(home, ".claude", "mcp_servers.json") + assert.Equal(t, adapter.RenderedFile, seen[mcpDest].Kind) + body := string(seen[mcpDest].Content) + assert.Contains(t, body, "filepuff") + assert.Contains(t, body, "/bin/filepuff") + assert.Contains(t, body, "\"type\": \"stdio\"") } -func TestRenderMergesExistingSettings(t *testing.T) { +func TestRenderMergesLiveClaudeJSON(t *testing.T) { home := t.TempDir() base := filepath.Join(home, ".claude") require.NoError(t, os.MkdirAll(base, 0o750)) - // Existing settings.json with user-managed keys. + // Existing ~/.claude.json with user-managed top-level keys. existing := map[string]any{ - "hooks": map[string]any{"preToolUse": []string{"/bin/hook"}}, - "permissions": map[string]any{"allow": []string{"Bash"}}, - "env": map[string]any{"ANTHROPIC_DEFAULT_MODEL": "opus"}, + "projects": map[string]any{"/path": map[string]any{"x": 1}}, + "shellIntegration": true, + "mcpServers": map[string]any{ + "old": map[string]any{"command": "/bin/old", "type": "stdio"}, + }, } body, _ := json.Marshal(existing) - require.NoError(t, os.WriteFile(filepath.Join(base, "settings.json"), body, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".claude.json"), body, 0o600)) ad := New(WithHome(home)) b := &canonical.Bundle{ @@ -75,20 +78,20 @@ func TestRenderMergesExistingSettings(t *testing.T) { seen := map[string]adapter.File{} fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) - settingsDest := filepath.Join(base, "settings.json") + livePath := filepath.Join(home, ".claude.json") var parsed map[string]any - require.NoError(t, json.Unmarshal(seen[settingsDest].Content, &parsed)) + require.NoError(t, json.Unmarshal(seen[livePath].Content, &parsed)) - // User keys preserved. - assert.Contains(t, parsed, "hooks") - assert.Contains(t, parsed, "permissions") - assert.Contains(t, parsed, "env") - // MCP updated. + // User-managed top-level keys preserved. + assert.Contains(t, parsed, "projects") + assert.Contains(t, parsed, "shellIntegration") + // mcpServers replaced with canonical content. mcp := parsed["mcpServers"].(map[string]any) assert.Contains(t, mcp, "filepuff") - // MCP entries must NOT have a "type" field (claude-code style). + assert.NotContains(t, mcp, "old") + // Each entry uses Claude's "type" key. fp := mcp["filepuff"].(map[string]any) - assert.NotContains(t, fp, "type") + assert.Equal(t, "stdio", fp["type"]) } func TestImport(t *testing.T) { @@ -107,16 +110,17 @@ func TestImport(t *testing.T) { filepath.Join(base, "CLAUDE.md"), []byte("# global"), 0o600)) - settings, _ := json.Marshal(map[string]any{ + // Live ~/.claude.json with mcpServers (the primary location). + liveBody, _ := json.Marshal(map[string]any{ "mcpServers": map[string]any{ "filepuff": map[string]any{ - "command": "/bin/filepuff", - "transport": "stdio", + "command": "/bin/filepuff", + "type": "stdio", }, }, }) require.NoError(t, os.WriteFile( - filepath.Join(base, "settings.json"), settings, 0o600)) + filepath.Join(home, ".claude.json"), liveBody, 0o600)) ad := New(WithHome(home)) res, err := ad.Import(home) diff --git a/internal/adapters/claudecode/import.go b/internal/adapters/claudecode/import.go index 47d4ffb..82ca6aa 100644 --- a/internal/adapters/claudecode/import.go +++ b/internal/adapters/claudecode/import.go @@ -38,10 +38,27 @@ func importFrom(home string) (*adapter.ImportResult, error) { } res.Instructions = body - servers, err := common.ImportMCPFromJSONFile(filepath.Join(base, "settings.json"), "mcpServers") - if err != nil { - return nil, err + // Claude Code stores MCP servers across several files. Read all known + // locations and dedupe by name; ~/.claude.json is the live source so + // its entries win on conflict. + mcpSources := []string{ + filepath.Join(home, ".claude.json"), + filepath.Join(base, "mcp_servers.json"), + filepath.Join(base, "settings.json"), + } + seen := map[string]bool{} + for _, p := range mcpSources { + servers, err := common.ImportMCPFromJSONFile(p, "mcpServers") + if err != nil { + return nil, err + } + for _, s := range servers { + if seen[s.Name] { + continue + } + seen[s.Name] = true + res.MCP = append(res.MCP, s) + } } - res.MCP = servers return res, nil }