1// config.go — MCP server configuration loading.
2//
3// Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
4// from two locations:
5//
6// 1. Global: $ZOT_HOME/mcp.json
7// 2. Project: .zot/mcp.json (in the current working directory)
8//
9// Project config overrides global config per-server (shallow merge).
10package main
11
12import (
13 "encoding/json"
14 "errors"
15 "fmt"
16 "io/fs"
17 "os"
18 "path/filepath"
19 "regexp"
20 "runtime"
21)
22
23// ServerConfig describes one MCP server entry.
24type ServerConfig struct {
25 // Stdio transport fields
26 Command string `json:"command,omitempty"` // executable to spawn
27 Args []string `json:"args,omitempty"` // arguments
28 Env map[string]string `json:"env,omitempty"` // extra env vars
29
30 // HTTP transport fields
31 Transport string `json:"transport,omitempty"` // "stdio" (default) | "streamable-http" | "sse"
32 URL string `json:"url,omitempty"` // server URL for HTTP transports
33 Headers map[string]string `json:"headers,omitempty"` // custom HTTP headers
34
35 // Timeouts (in seconds)
36 ConnectTimeout int `json:"connectTimeout,omitempty"` // connection timeout (default: 30)
37 RequestTimeout int `json:"requestTimeout,omitempty"` // per-request timeout (default: 60)
38 IdleTimeout int `json:"idleTimeout,omitempty"` // idle timeout before stopping (default: 300)
39}
40
41// Config is the top-level MCP configuration.
42type Config struct {
43 MCPServers map[string]ServerConfig `json:"mcpServers"`
44}
45
46// zotHome returns the zot state directory.
47func zotHome() string {
48 if h := os.Getenv("ZOT_HOME"); h != "" {
49 return h
50 }
51 if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
52 return filepath.Join(xdg, "zot")
53 }
54 switch runtime.GOOS {
55 case "darwin":
56 home, _ := os.UserHomeDir()
57 return filepath.Join(home, "Library", "Application Support", "zot")
58 case "windows":
59 return filepath.Join(os.Getenv("APPDATA"), "zot")
60 default: // linux, freebsd, etc.
61 home, _ := os.UserHomeDir()
62 return filepath.Join(home, ".local", "state", "zot")
63 }
64}
65
66// loadConfig reads and merges global + project MCP configs.
67// cwd is the current working directory (for project config lookup).
68func loadConfig(cwd string) (Config, error) {
69 cfg := Config{MCPServers: make(map[string]ServerConfig)}
70
71 // 1. Global config
72 globalPath := filepath.Join(zotHome(), "mcp.json")
73 if err := mergeConfig(&cfg, globalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
74 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
75 }
76
77 var configErrors []error
78
79 // 2. Project config (overrides global per-server)
80 if cwd != "" {
81 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
82 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
83 configErrors = append(configErrors, fmt.Errorf("project config %s: %w", projectPath, err))
84 }
85 }
86
87 // Retained global servers must be expanded even if the project config failed.
88 for name, srv := range cfg.MCPServers {
89 if err := expandServerEnv(&srv); err != nil {
90 delete(cfg.MCPServers, name)
91 configErrors = append(configErrors, fmt.Errorf("server %q: %w", name, err))
92 continue
93 }
94 cfg.MCPServers[name] = srv
95 }
96 return cfg, errors.Join(configErrors...)
97}
98
99// Expand only Claude Code's braced syntax, not shell expressions or bare $VAR.
100var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
101
102func expandServerEnv(srv *ServerConfig) error {
103 var missing []error
104 expand := func(field, value string) string {
105 return envReference.ReplaceAllStringFunc(value, func(ref string) string {
106 parts := envReference.FindStringSubmatch(ref)
107 if value, ok := os.LookupEnv(parts[1]); ok {
108 return value
109 }
110 if parts[2] != "" {
111 return parts[3]
112 }
113 // Never include field values: they may contain credentials.
114 missing = append(missing, fmt.Errorf("%s: environment variable %s is not set", field, parts[1]))
115 return ref
116 })
117 }
118 srv.Command = expand("command", srv.Command)
119 for i, arg := range srv.Args {
120 srv.Args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
121 }
122 for k, value := range srv.Env {
123 srv.Env[k] = expand("env", value)
124 }
125 srv.URL = expand("url", srv.URL)
126 for k, value := range srv.Headers {
127 srv.Headers[k] = expand("headers", value)
128 }
129 return errors.Join(missing...)
130}
131
132// mergeConfig reads a JSON config file and merges its servers into cfg.
133func mergeConfig(cfg *Config, path string) error {
134 data, err := os.ReadFile(path)
135 if err != nil {
136 return err
137 }
138 var file Config
139 if err := json.Unmarshal(data, &file); err != nil {
140 return fmt.Errorf("parse %s: %w", path, err)
141 }
142 for name, srv := range file.MCPServers {
143 // Apply defaults
144 if srv.Transport == "" {
145 srv.Transport = "stdio"
146 }
147 if srv.ConnectTimeout == 0 {
148 srv.ConnectTimeout = 30
149 }
150 if srv.RequestTimeout == 0 {
151 srv.RequestTimeout = 60
152 }
153 if srv.IdleTimeout == 0 {
154 srv.IdleTimeout = 300
155 }
156 cfg.MCPServers[name] = srv
157 }
158 return nil
159}