Vectrum
Sign In
249 lines · 7.9 KB · Go
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
}