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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user