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
+83 -16
View File
@@ -56,33 +56,100 @@ func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) {
} }
// ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be // 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) { 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 var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(clean), &raw); err != nil { if err := json.Unmarshal(body, &raw); err != nil {
return nil, nil // tolerate malformed config clean := StripJSONComments(string(body))
if err := json.Unmarshal([]byte(clean), &raw); err != nil {
return nil, nil
}
} }
nested, ok := raw[key] nested, ok := raw[key]
if !ok { if !ok {
return nil, nil return nil, nil
} }
var entries map[string]struct { //nolint:govet // fieldalignment: map+slice+3 strings irreducible var entries map[string]json.RawMessage
Env map[string]string `json:"env"`
Args []string `json:"args"`
Command string `json:"command"`
URL string `json:"url"`
Transport string `json:"transport"`
}
if err := json.Unmarshal(nested, &entries); err != nil { if err := json.Unmarshal(nested, &entries); err != nil {
return nil, nil return nil, nil
} }
out := make([]canonical.MCPServer, 0, len(entries)) out := make([]canonical.MCPServer, 0, len(entries))
for n, v := range entries { for n, raw := range entries {
out = append(out, canonical.MCPServer{ srv, ok := parseMCPEntry(n, raw)
Name: n, Command: v.Command, Args: v.Args, URL: v.URL, if !ok {
Transport: v.Transport, Env: v.Env, 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 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
}
+21 -12
View File
@@ -32,20 +32,29 @@ func mcpEntry(s canonical.MCPServer, style MCPStyle) map[string]any {
e := map[string]any{} e := map[string]any{}
switch style { switch style {
case MCPClaudeStyle: case MCPClaudeStyle:
if s.Command != "" { // Claude Code's actual entry shape uses "type" (not "transport").
e["command"] = s.Command // Default to stdio when no URL is set.
} t := s.Transport
if len(s.Args) > 0 {
e["args"] = s.Args
}
if s.URL != "" { if s.URL != "" {
if t == "" {
t = "http"
}
e["type"] = t
e["url"] = s.URL e["url"] = s.URL
} } else {
if s.Transport != "" { if t == "" {
e["transport"] = s.Transport t = "stdio"
} }
if len(s.Env) > 0 { e["type"] = t
e["env"] = s.Env 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: case MCPCrushStyle:
if s.URL != "" { if s.URL != "" {
+5 -4
View File
@@ -14,14 +14,15 @@ func reg(servers ...canonical.MCPServer) *canonical.MCPRegistry {
} }
func TestBuildMCPMapStyledClaudeStyle(t *testing.T) { 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) out := BuildMCPMapStyled(r, MCPClaudeStyle)
require.Contains(t, out, "fp") require.Contains(t, out, "fp")
e := out["fp"].(map[string]any) e := out["fp"].(map[string]any)
assert.Equal(t, "/bin/fp", e["command"]) assert.Equal(t, "/bin/fp", e["command"])
assert.Equal(t, []string{"--flag"}, e["args"]) assert.Equal(t, []string{"--flag"}, e["args"])
assert.Equal(t, "stdio", e["transport"]) // Claude's actual entry shape uses "type" (defaults to stdio when no URL).
assert.NotContains(t, e, "type") assert.Equal(t, "stdio", e["type"])
assert.NotContains(t, e, "transport")
} }
func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) { func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) {
@@ -29,7 +30,7 @@ func TestBuildMCPMapStyledClaudeStyleURL(t *testing.T) {
out := BuildMCPMapStyled(r, MCPClaudeStyle) out := BuildMCPMapStyled(r, MCPClaudeStyle)
e := out["remote"].(map[string]any) e := out["remote"].(map[string]any)
assert.Equal(t, "https://example.com", e["url"]) assert.Equal(t, "https://example.com", e["url"])
assert.NotContains(t, e, "type") assert.Equal(t, "http", e["type"])
} }
func TestBuildMCPMapStyledCrushStdio(t *testing.T) { func TestBuildMCPMapStyledCrushStdio(t *testing.T) {
+28 -8
View File
@@ -78,21 +78,41 @@ func (a *Adapter) Render(b *canonical.Bundle) (*adapter.FileSet, error) {
Content: []byte(instructions), Content: []byte(instructions),
}) })
settingsPath := filepath.Join(base, "settings.json") // Claude Code reads MCP servers from two files on disk:
existing, _ := os.ReadFile(settingsPath) // * ~/.claude.json (live, written by `claude mcp add`)
overlay := map[string]any{ // * ~/.claude/mcp_servers.json (older / fallback location)
"mcpServers": common.BuildMCPMapStyled(&b.MCP, common.MCPClaudeStyle), // Write the dedicated file as the canonical destination, AND merge the
} // same map into ~/.claude.json (preserving every other key in that
merged, err := common.MergeJSONKeys(existing, overlay) // 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 { if err != nil {
return nil, err return nil, err
} }
fs.Add(adapter.File{ fs.Add(adapter.File{
Dest: settingsPath, Dest: dedicatedPath,
Kind: adapter.RenderedFile, 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 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.Equal(t, adapter.RenderedFile, seen[claudemdDest].Kind)
assert.Contains(t, string(seen[claudemdDest].Content), "# global") assert.Contains(t, string(seen[claudemdDest].Content), "# global")
settingsDest := filepath.Join(home, ".claude", "settings.json") mcpDest := filepath.Join(home, ".claude", "mcp_servers.json")
assert.Equal(t, adapter.RenderedFile, seen[settingsDest].Kind) assert.Equal(t, adapter.RenderedFile, seen[mcpDest].Kind)
settingsBody := string(seen[settingsDest].Content) body := string(seen[mcpDest].Content)
assert.Contains(t, settingsBody, "filepuff") assert.Contains(t, body, "filepuff")
assert.Contains(t, settingsBody, "/bin/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() home := t.TempDir()
base := filepath.Join(home, ".claude") base := filepath.Join(home, ".claude")
require.NoError(t, os.MkdirAll(base, 0o750)) 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{ existing := map[string]any{
"hooks": map[string]any{"preToolUse": []string{"/bin/hook"}}, "projects": map[string]any{"/path": map[string]any{"x": 1}},
"permissions": map[string]any{"allow": []string{"Bash"}}, "shellIntegration": true,
"env": map[string]any{"ANTHROPIC_DEFAULT_MODEL": "opus"}, "mcpServers": map[string]any{
"old": map[string]any{"command": "/bin/old", "type": "stdio"},
},
} }
body, _ := json.Marshal(existing) 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)) ad := New(WithHome(home))
b := &canonical.Bundle{ b := &canonical.Bundle{
@@ -75,20 +78,20 @@ func TestRenderMergesExistingSettings(t *testing.T) {
seen := map[string]adapter.File{} seen := map[string]adapter.File{}
fs.ForEach(func(f adapter.File) { seen[f.Dest] = f }) 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 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. // User-managed top-level keys preserved.
assert.Contains(t, parsed, "hooks") assert.Contains(t, parsed, "projects")
assert.Contains(t, parsed, "permissions") assert.Contains(t, parsed, "shellIntegration")
assert.Contains(t, parsed, "env") // mcpServers replaced with canonical content.
// MCP updated.
mcp := parsed["mcpServers"].(map[string]any) mcp := parsed["mcpServers"].(map[string]any)
assert.Contains(t, mcp, "filepuff") 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) fp := mcp["filepuff"].(map[string]any)
assert.NotContains(t, fp, "type") assert.Equal(t, "stdio", fp["type"])
} }
func TestImport(t *testing.T) { func TestImport(t *testing.T) {
@@ -107,16 +110,17 @@ func TestImport(t *testing.T) {
filepath.Join(base, "CLAUDE.md"), filepath.Join(base, "CLAUDE.md"),
[]byte("# global"), 0o600)) []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{ "mcpServers": map[string]any{
"filepuff": map[string]any{ "filepuff": map[string]any{
"command": "/bin/filepuff", "command": "/bin/filepuff",
"transport": "stdio", "type": "stdio",
}, },
}, },
}) })
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
filepath.Join(base, "settings.json"), settings, 0o600)) filepath.Join(home, ".claude.json"), liveBody, 0o600))
ad := New(WithHome(home)) ad := New(WithHome(home))
res, err := ad.Import(home) res, err := ad.Import(home)
+21 -4
View File
@@ -38,10 +38,27 @@ func importFrom(home string) (*adapter.ImportResult, error) {
} }
res.Instructions = body res.Instructions = body
servers, err := common.ImportMCPFromJSONFile(filepath.Join(base, "settings.json"), "mcpServers") // Claude Code stores MCP servers across several files. Read all known
if err != nil { // locations and dedupe by name; ~/.claude.json is the live source so
return nil, err // 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 return res, nil
} }