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