mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-17 01:17:35 +00:00
fix(mcp): read .claude.json + mcp_servers.json, handle type/transport + array commands
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user