fix(mcp): read .claude.json + mcp_servers.json, handle type/transport + array commands

This commit is contained in:
2026-05-23 11:41:57 +01:00
parent 9633f9ad4c
commit 8f5200a7f2
6 changed files with 186 additions and 68 deletions
+28 -8
View File
@@ -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
}
+28 -24
View File
@@ -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)
+21 -4
View File
@@ -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
}