531 lines
16 KiB
Go
531 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/mcpdiag"
|
|
)
|
|
|
|
// mcpJSONFile is the project-root file Claude Code calls .mcp.json. Reasonix reads
|
|
// it so an MCP server already configured for Claude works here unchanged — the
|
|
// server specs map field-for-field onto PluginEntry.
|
|
const mcpJSONFile = ".mcp.json"
|
|
|
|
// mcpServerSpec mirrors one entry of Claude Code's "mcpServers" map. The field
|
|
// names and semantics match PluginEntry: command/args/env describe a local
|
|
// stdio server; type/url/headers describe a remote one. Reasonix also accepts
|
|
// timeout fields as MCP call policy extensions.
|
|
type mcpServerSpec struct {
|
|
Type string `json:"type"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
Env map[string]string `json:"env"`
|
|
URL string `json:"url"`
|
|
Headers map[string]string `json:"headers"`
|
|
CallTimeoutSeconds int `json:"call_timeout_seconds"`
|
|
ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"`
|
|
TrustedReadOnlyTools []string `json:"trusted_read_only_tools"`
|
|
AutoStart *bool `json:"auto_start"`
|
|
}
|
|
|
|
// loadMCPJSON reads path (Claude Code's .mcp.json) and returns its servers as
|
|
// PluginEntry values, sorted by name for a stable connection order. An absent
|
|
// file is not an error (returns nil, nil). A present-but-malformed file is an
|
|
// error so a typo surfaces loudly instead of silently dropping every server.
|
|
func loadMCPJSON(path string) ([]PluginEntry, error) {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
var doc struct {
|
|
MCPServers map[string]mcpServerSpec `json:"mcpServers"`
|
|
}
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
return nil, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
return specsToEntries(doc.MCPServers, nil), nil
|
|
}
|
|
|
|
// LoadMCPJSONPlugin returns one server entry from a Claude-compatible .mcp.json.
|
|
func LoadMCPJSONPlugin(path, name string) (PluginEntry, bool, error) {
|
|
entries, err := loadMCPJSON(path)
|
|
if err != nil {
|
|
return PluginEntry{}, false, err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.Name == name {
|
|
return entry, true, nil
|
|
}
|
|
}
|
|
return PluginEntry{}, false, nil
|
|
}
|
|
|
|
// specsToEntries converts an mcpServers map to PluginEntry values, sorted by name
|
|
// for a stable connection order. Names in skip are dropped (used for v0.x's
|
|
// mcpDisabled list).
|
|
func specsToEntries(specs map[string]mcpServerSpec, skip map[string]bool) []PluginEntry {
|
|
names := make([]string, 0, len(specs))
|
|
for name := range specs {
|
|
if !skip[name] {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
entries := make([]PluginEntry, 0, len(names))
|
|
for _, name := range names {
|
|
entries = append(entries, pluginEntryFromMCPSpec(name, specs[name]))
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// legacyConfigPath is the v0.x (TypeScript line) config file, ~/.reasonix/config.json.
|
|
func legacyConfigPath() string {
|
|
if IsolatedHomeDir() != "" {
|
|
return ""
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return filepath.Join(home, ".reasonix", "config.json")
|
|
}
|
|
|
|
// loadLegacyMCP reads the v0.x ~/.reasonix/config.json and returns its enabled
|
|
// MCP servers as PluginEntry values — both the canonical mcpServers map and the
|
|
// older `mcp` string list (mcpServers wins on a name collision, matching v0.x;
|
|
// servers listed in mcpDisabled are skipped) — so upgrading from v0.x keeps MCP
|
|
// servers working without rewriting them as [[plugins]]. Absent or malformed →
|
|
// nil: a stale legacy file must never block startup, and it is the
|
|
// lowest-priority source anyway (the v2 config and .mcp.json win on a name
|
|
// collision — see Load).
|
|
func loadLegacyMCP(path string) []PluginEntry {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var doc struct {
|
|
MCP []string `json:"mcp"`
|
|
MCPServers map[string]mcpServerSpec `json:"mcpServers"`
|
|
MCPEnv map[string]map[string]string `json:"mcpEnv"`
|
|
MCPDisabled []string `json:"mcpDisabled"`
|
|
}
|
|
if err := json.Unmarshal(b, &doc); err != nil {
|
|
return nil
|
|
}
|
|
disabled := make(map[string]bool, len(doc.MCPDisabled))
|
|
for _, n := range doc.MCPDisabled {
|
|
disabled[n] = true
|
|
}
|
|
entries := specsToEntries(doc.MCPServers, disabled)
|
|
have := make(map[string]bool, len(entries))
|
|
for _, e := range entries {
|
|
have[e.Name] = true
|
|
}
|
|
for i, raw := range doc.MCP {
|
|
pe, ok := parseLegacyMCPSpec(raw)
|
|
if !ok || disabled[pe.Name] {
|
|
continue
|
|
}
|
|
if pe.Name == "" {
|
|
pe.Name = anonymousMCPName(i)
|
|
} else if pe.Command != "" {
|
|
pe.Env = doc.MCPEnv[pe.Name]
|
|
}
|
|
if have[pe.Name] {
|
|
continue
|
|
}
|
|
have[pe.Name] = true
|
|
pe, _ = NormalizePluginCommandLine(pe)
|
|
entries = append(entries, pe)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
var legacyMCPSpecName = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)=(.*)$`)
|
|
|
|
// parseLegacyMCPSpec parses one v0.x `--mcp`-format string: "name=cmd args...",
|
|
// "name=https://url" (SSE), or "name=streamable+https://url" (streamable HTTP);
|
|
// the name= prefix is optional.
|
|
func parseLegacyMCPSpec(raw string) (PluginEntry, bool) {
|
|
body := strings.TrimSpace(raw)
|
|
var name string
|
|
if m := legacyMCPSpecName.FindStringSubmatch(body); m != nil {
|
|
name, body = m[1], strings.TrimSpace(m[2])
|
|
}
|
|
if body == "" {
|
|
return PluginEntry{}, false
|
|
}
|
|
lower := strings.ToLower(body)
|
|
if strings.HasPrefix(lower, "streamable+http://") || strings.HasPrefix(lower, "streamable+https://") {
|
|
return PluginEntry{Name: name, Type: "http", URL: body[len("streamable+"):]}, true
|
|
}
|
|
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
|
return PluginEntry{Name: name, Type: "sse", URL: body}, true
|
|
}
|
|
parts, ok := splitPluginCommandLine(body)
|
|
if !ok || len(parts) == 0 {
|
|
return PluginEntry{}, false
|
|
}
|
|
if shouldSplitPluginCommand(body, parts[0]) {
|
|
return PluginEntry{Name: name, Command: parts[0], Args: parts[1:]}, true
|
|
}
|
|
return PluginEntry{Name: name, Command: body}, true
|
|
}
|
|
|
|
// anonymousMCPName names a v0.x spec that carried no name= prefix (its tools
|
|
// registered unprefixed in v0.x; v1+ plugins require a name).
|
|
func anonymousMCPName(i int) string {
|
|
return fmt.Sprintf("mcp-%d", i+1)
|
|
}
|
|
|
|
func pluginEntryFromMCPSpec(name string, s mcpServerSpec) PluginEntry {
|
|
e := PluginEntry{
|
|
Name: name,
|
|
Type: s.Type,
|
|
Command: s.Command,
|
|
Args: s.Args,
|
|
Env: s.Env,
|
|
URL: s.URL,
|
|
Headers: s.Headers,
|
|
CallTimeoutSeconds: s.CallTimeoutSeconds,
|
|
ToolTimeoutSeconds: s.ToolTimeoutSeconds,
|
|
TrustedReadOnlyTools: s.TrustedReadOnlyTools,
|
|
AutoStart: s.AutoStart,
|
|
}
|
|
e, _ = NormalizePluginCommandLine(e)
|
|
return e
|
|
}
|
|
|
|
// mergeMCPJSON appends servers from .mcp.json that the TOML config did not
|
|
// already declare. reasonix.toml's [[plugins]] win on a name collision: it is the
|
|
// Reasonix-specific, more explicit of the two, so it overrides the shared,
|
|
// checked-in .mcp.json rather than the other way round.
|
|
func (c *Config) mergeMCPJSON(entries []PluginEntry) {
|
|
have := make(map[string]bool, len(c.Plugins))
|
|
for _, p := range c.Plugins {
|
|
have[p.Name] = true
|
|
}
|
|
for _, e := range entries {
|
|
if have[e.Name] {
|
|
continue
|
|
}
|
|
have[e.Name] = true
|
|
c.Plugins = append(c.Plugins, e)
|
|
}
|
|
}
|
|
|
|
// UpsertMCPJSONPlugin writes one MCP server to a Claude-compatible .mcp.json
|
|
// file, preserving unrelated top-level fields and unknown per-server fields.
|
|
func UpsertMCPJSONPlugin(path string, entry PluginEntry) (bool, error) {
|
|
entry, _ = NormalizePluginCommandLine(entry)
|
|
if err := validatePlugin(entry); err != nil {
|
|
return false, err
|
|
}
|
|
root, servers, err := readMCPJSONRaw(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
raw, existed := servers[entry.Name]
|
|
server := map[string]json.RawMessage{}
|
|
if existed && len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &server); err != nil || server == nil {
|
|
return false, fmt.Errorf("mcp config %s: server %q is not an object", path, entry.Name)
|
|
}
|
|
}
|
|
applyPluginEntryToMCPJSONServer(server, entry)
|
|
updatedRaw, err := json.Marshal(server)
|
|
if err != nil {
|
|
return false, fmt.Errorf("mcp config %s: server %q: %w", path, entry.Name, err)
|
|
}
|
|
servers[entry.Name] = updatedRaw
|
|
if err := writeMCPJSONServers(path, root, servers); err != nil {
|
|
return false, err
|
|
}
|
|
return !existed, nil
|
|
}
|
|
|
|
// RemoveMCPJSONPlugin removes one MCP server from a Claude-compatible .mcp.json
|
|
// file. Missing files or missing entries are reported as unchanged.
|
|
func RemoveMCPJSONPlugin(path, name string) (bool, error) {
|
|
root, servers, err := readMCPJSONRaw(path)
|
|
if os.IsNotExist(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, ok := servers[name]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(servers, name)
|
|
if err := writeMCPJSONServers(path, root, servers); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func trustMCPJSONReadOnlyTool(path, name, toolName string) (PluginEntry, bool, error) {
|
|
entry, found, err := LoadMCPJSONPlugin(path, name)
|
|
if err != nil {
|
|
return PluginEntry{}, false, err
|
|
}
|
|
if !found {
|
|
return PluginEntry{}, false, fmt.Errorf("trust plugin read-only tool: no plugin %q", name)
|
|
}
|
|
cfg := &Config{Plugins: []PluginEntry{entry}}
|
|
updated, changed, err := cfg.TrustPluginReadOnlyTool(name, toolName)
|
|
if err != nil {
|
|
return PluginEntry{}, false, err
|
|
}
|
|
if !changed {
|
|
return updated, false, nil
|
|
}
|
|
if _, err := UpsertMCPJSONPlugin(path, updated); err != nil {
|
|
return PluginEntry{}, false, err
|
|
}
|
|
return updated, true, nil
|
|
}
|
|
|
|
func readMCPJSONRaw(path string) (map[string]json.RawMessage, map[string]json.RawMessage, error) {
|
|
root := map[string]json.RawMessage{}
|
|
servers := map[string]json.RawMessage{}
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if os.IsNotExist(err) {
|
|
return root, servers, nil
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
if err := json.Unmarshal(b, &root); err != nil {
|
|
return nil, nil, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
raw, ok := root["mcpServers"]
|
|
if !ok || len(raw) == 0 || string(raw) == "null" {
|
|
return root, servers, nil
|
|
}
|
|
if err := json.Unmarshal(raw, &servers); err != nil || servers == nil {
|
|
return nil, nil, fmt.Errorf("mcp config %s: mcpServers must be an object", path)
|
|
}
|
|
return root, servers, nil
|
|
}
|
|
|
|
func applyPluginEntryToMCPJSONServer(server map[string]json.RawMessage, entry PluginEntry) {
|
|
transport := strings.ToLower(strings.TrimSpace(entry.Type))
|
|
if transport == "" {
|
|
transport = "stdio"
|
|
}
|
|
if transport == "stdio" {
|
|
delete(server, "type")
|
|
setMCPJSONString(server, "command", strings.TrimSpace(entry.Command))
|
|
setMCPJSONStringArray(server, "args", entry.Args)
|
|
setMCPJSONStringMap(server, "env", entry.Env)
|
|
delete(server, "url")
|
|
delete(server, "headers")
|
|
} else {
|
|
setMCPJSONString(server, "type", transport)
|
|
setMCPJSONString(server, "url", strings.TrimSpace(entry.URL))
|
|
setMCPJSONStringMap(server, "headers", entry.Headers)
|
|
setMCPJSONStringMap(server, "env", entry.Env)
|
|
delete(server, "command")
|
|
delete(server, "args")
|
|
}
|
|
setMCPJSONInt(server, "call_timeout_seconds", entry.CallTimeoutSeconds)
|
|
setMCPJSONIntMap(server, "tool_timeout_seconds", entry.ToolTimeoutSeconds)
|
|
setMCPJSONStringArray(server, "trusted_read_only_tools", entry.TrustedReadOnlyTools)
|
|
setMCPJSONBool(server, "auto_start", entry.AutoStart)
|
|
}
|
|
|
|
func writeMCPJSONServers(path string, root map[string]json.RawMessage, servers map[string]json.RawMessage) error {
|
|
serversRaw, err := json.Marshal(servers)
|
|
if err != nil {
|
|
return fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
root["mcpServers"] = serversRaw
|
|
return writeMCPJSON(path, root)
|
|
}
|
|
|
|
func clearMCPJSONAuthentication(path, name string) (PluginEntry, bool, error) {
|
|
b, err := fileencoding.ReadFileUTF8(path)
|
|
if os.IsNotExist(err) {
|
|
return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
|
|
}
|
|
if err != nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &root); err != nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
var servers map[string]json.RawMessage
|
|
if err := json.Unmarshal(root["mcpServers"], &servers); err != nil || servers == nil {
|
|
return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
|
|
}
|
|
raw, ok := servers[name]
|
|
if !ok {
|
|
return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name)
|
|
}
|
|
var spec mcpServerSpec
|
|
if err := json.Unmarshal(raw, &spec); err != nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err)
|
|
}
|
|
cleanHeaders, cleanEnv, cleanURL, changed := mcpdiag.ClearAuthConfig(spec.Headers, spec.Env, spec.URL)
|
|
if !changed {
|
|
return pluginEntryFromMCPSpec(name, spec), false, nil
|
|
}
|
|
spec.Headers = cleanHeaders
|
|
spec.Env = cleanEnv
|
|
spec.URL = cleanURL
|
|
|
|
var server map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &server); err != nil || server == nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q is not an object", path, name)
|
|
}
|
|
setMCPJSONStringMap(server, "headers", cleanHeaders)
|
|
setMCPJSONStringMap(server, "env", cleanEnv)
|
|
setMCPJSONString(server, "url", cleanURL)
|
|
updatedRaw, err := json.Marshal(server)
|
|
if err != nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err)
|
|
}
|
|
servers[name] = updatedRaw
|
|
serversRaw, err := json.Marshal(servers)
|
|
if err != nil {
|
|
return PluginEntry{}, false, fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
root["mcpServers"] = serversRaw
|
|
if err := writeMCPJSON(path, root); err != nil {
|
|
return PluginEntry{}, false, err
|
|
}
|
|
return pluginEntryFromMCPSpec(name, spec), true, nil
|
|
}
|
|
|
|
func setMCPJSONStringMap(server map[string]json.RawMessage, key string, values map[string]string) {
|
|
if len(values) == 0 {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func setMCPJSONString(server map[string]json.RawMessage, key, value string) {
|
|
if value == "" {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func setMCPJSONStringArray(server map[string]json.RawMessage, key string, values []string) {
|
|
if len(values) == 0 {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func setMCPJSONInt(server map[string]json.RawMessage, key string, value int) {
|
|
if value <= 0 {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func setMCPJSONIntMap(server map[string]json.RawMessage, key string, values map[string]int) {
|
|
clean := make(map[string]int, len(values))
|
|
for k, v := range values {
|
|
if strings.TrimSpace(k) == "" || v <= 0 {
|
|
continue
|
|
}
|
|
clean[k] = v
|
|
}
|
|
if len(clean) == 0 {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(clean)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func setMCPJSONBool(server map[string]json.RawMessage, key string, value *bool) {
|
|
if value == nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
raw, err := json.Marshal(*value)
|
|
if err != nil {
|
|
delete(server, key)
|
|
return
|
|
}
|
|
server[key] = raw
|
|
}
|
|
|
|
func writeMCPJSON(path string, root map[string]json.RawMessage) error {
|
|
out, err := json.MarshalIndent(root, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("mcp config %s: %w", path, err)
|
|
}
|
|
out = append(out, '\n')
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("mcp config %s: create dir: %w", path, err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".mcp.*.json.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("mcp config %s: create temp: %w", path, err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
if _, err := tmp.Write(out); err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("mcp config %s: write: %w", path, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("mcp config %s: close temp: %w", path, err)
|
|
}
|
|
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|