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
}