package ccsettings import ( "fmt" "path/filepath" "regexp" "sort" "strings" "github.com/johannSo/codey/internal/config" ) // ServerDef is one MCP server as Claude Code writes it, before any ${VAR} in // it is expanded. It is kept unexpanded because that is the form the trust // decision is about: what the repository wrote, not what this shell happened // to substitute into it. type ServerDef struct { // Type is "stdio", or empty, which means the same thing. "http", "sse" // and "ws" are remote transports Codey's client does not speak. Type string `json:"type,omitempty"` Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` URL string `json:"url,omitempty"` } // stdio reports whether Codey can start this server. A url is a remote // server whatever the type says. func (d ServerDef) stdio() bool { return (d.Type == "" || d.Type == "stdio") && d.URL == "" } // MCPFile is the servers one Claude Code file defines, the remote ones // already removed. type MCPFile struct { Scope Scope // Shown names the file in messages; for ~/.claude.json's per-project // servers it says which project. Shown string Servers map[string]ServerDef } // mcpJSON is the shape of .mcp.json, and of the parts of ~/.claude.json read // here. ~/.claude.json carries a great deal more — login state, tips shown, // per-project history — none of which is any of Codey's business. type mcpJSON struct { MCPServers map[string]ServerDef `json:"mcpServers"` Projects map[string]struct { MCPServers map[string]ServerDef `json:"mcpServers"` } `json:"projects"` } // LoadMCP reads the MCP servers Claude Code would offer in root, from the // three places it keeps them, most specific first: // // - ~/.claude.json, under projects[root] — "local" scope, the user's own // servers for this one project, which is what `claude mcp add` writes; // - .mcp.json at the root — "project" scope, committed to the repository; // - ~/.claude.json, top-level — "user" scope, for every project. // // That is Claude Code's own order of precedence, and the order MergeMCP // resolves name clashes in. Servers Codey cannot start — a url, or a type // other than stdio — are reported and left out here, so nothing downstream // has to know they existed. func LoadMCP(home, root string) ([]MCPFile, []error) { var errs []error var claude mcpJSON claudePath := "" if home != "" { claudePath = filepath.Join(home, ClaudeJSON) if _, err := readJSON(claudePath, &claude); err != nil { errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) } } var files []MCPFile add := func(scope Scope, label string, defs map[string]ServerDef) { if len(defs) == 0 { return } kept := map[string]ServerDef{} for _, name := range sortedNames(defs) { d := defs[name] if !d.stdio() { transport := d.Type if transport == "" || transport == "stdio" { transport = "url" } errs = append(errs, fmt.Errorf("mcp: %s in %s is a remote server (%s), and Codey's client speaks only stdio — skipped", name, label, transport)) continue } kept[name] = d } if len(kept) > 0 { files = append(files, MCPFile{Scope: scope, Shown: label, Servers: kept}) } } if claudePath != "" && root != "" { // Claude Code keys a project by the directory it was started in. A // root reached through a symlink is the same project under another // name, so both spellings are tried. for _, key := range rootKeys(root) { if p, ok := claude.Projects[key]; ok { add(ScopeLocal, "~/"+ClaudeJSON+" (this project)", p.MCPServers) break } } } if root != "" { var project mcpJSON path := filepath.Join(root, ProjectMCP) if _, err := readJSON(path, &project); err != nil { errs = append(errs, fmt.Errorf("claude code mcp: %w", err)) } else { add(ScopeProject, ProjectMCP, project.MCPServers) } } if claudePath != "" { add(ScopeUser, "~/"+ClaudeJSON, claude.MCPServers) } return files, errs } func rootKeys(root string) []string { keys := []string{filepath.Clean(root)} if real, err := filepath.EvalSymlinks(root); err == nil && real != keys[0] { keys = append(keys, real) } return keys } // Project is the project-scope file in files, or nil. func Project(files []MCPFile) *MCPFile { for i := range files { if files[i].Scope == ScopeProject { return &files[i] } } return nil } // MergeMCP combines Codey's own servers with Claude Code's into the set to // start, resolving each name to one definition and expanding ${VAR} in it. // // Codey's own config wins a clash, because it is the file written for this // program; after it, files are taken in the order LoadMCP returned them. Each // clash is reported once, naming the winner, because a server that silently // turned out to be a different program is the kind of surprise nobody thinks // to debug. // // trusted says whether the project's .mcp.json may be started. When it may // not, its servers are dropped before anything else is decided — so a user's // own server with the same name is the one that runs, rather than nothing. // // lookup is the environment ${VAR} is expanded from: os.LookupEnv in // production, a map in the tests. func MergeMCP(own map[string]config.MCPServer, files []MCPFile, trusted bool, lookup func(string) (string, bool)) (map[string]config.MCPServer, []string) { out := map[string]config.MCPServer{} from := map[string]string{} for name, s := range own { out[name] = s from[name] = "Codey's config" } var notes []string for _, f := range files { if f.Scope == ScopeProject && !trusted { continue } for _, name := range sortedNames(f.Servers) { if winner, taken := from[name]; taken { notes = append(notes, fmt.Sprintf("mcp: %s is defined in both %s and %s — using the one in %s", name, winner, f.Shown, winner)) continue } spec, err := expandServer(f.Servers[name], lookup) if err != nil { notes = append(notes, fmt.Sprintf("mcp: %s in %s: %v — skipped", name, f.Shown, err)) // Claimed all the same: a broken definition in a more specific // file should not quietly hand the name to a less specific one. from[name] = f.Shown continue } out[name] = spec from[name] = f.Shown } } return out, notes } // envRef is ${VAR} or ${VAR:-default}, the two forms Claude Code expands in a // server definition. var envRef = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`) // expand substitutes every ${VAR} in s. A variable that is unset, or set to // nothing, takes its default when it has one — the shell's reading of :- — // and is an error when it has none: Claude Code refuses such a definition // rather than starting a server with a hole in its command line, and so does // this. func expand(s string, lookup func(string) (string, bool)) (string, error) { var missing []string out := envRef.ReplaceAllStringFunc(s, func(ref string) string { m := envRef.FindStringSubmatch(ref) if v, ok := lookup(m[1]); ok && v != "" { return v } if m[2] != "" { return m[3] } missing = append(missing, m[1]) return "" }) if len(missing) > 0 { return "", fmt.Errorf("%s is not set and has no default", strings.Join(missing, ", ")) } return out, nil } func expandServer(d ServerDef, lookup func(string) (string, bool)) (config.MCPServer, error) { var err error s := config.MCPServer{} if s.Command, err = expand(d.Command, lookup); err != nil { return s, err } for _, a := range d.Args { v, err := expand(a, lookup) if err != nil { return s, err } s.Args = append(s.Args, v) } if len(d.Env) > 0 { s.Env = map[string]string{} for k, v := range d.Env { if s.Env[k], err = expand(v, lookup); err != nil { return s, err } } } return s, nil } func sortedNames[V any](m map[string]V) []string { names := make([]string, 0, len(m)) for n := range m { names = append(names, n) } sort.Strings(names) return names }