1// Package mcp is zut's native Model Context Protocol client. It loads
2// configured MCP servers, connects to them over stdio or streamable HTTP,
3// and exposes their tools and resources as core.Tool values.
4package mcp
5
6import (
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io/fs"
11 "os"
12 "path/filepath"
13 "regexp"
14 "sort"
15 "strings"
16 "time"
17)
18
19const (
20 // DefaultStartupTimeout bounds process start, initialize, and the first
21 // tools/list for one server.
22 DefaultStartupTimeout = 30 * time.Second
23 // DefaultToolTimeout bounds one tools/call request.
24 DefaultToolTimeout = 300 * time.Second
25)
26
27// ServerConfig describes one entry below "mcpServers". Field names follow the
28// Claude Desktop/Claude Code format; zut-specific options are additive.
29type ServerConfig struct {
30 // Type selects the transport: "stdio" (default when command is set) or
31 // "http" (default when url is set). "streamable-http" is an alias of
32 // "http". "transport" is accepted as an alias key.
33 Type string `json:"type,omitempty"`
34 Transport string `json:"transport,omitempty"`
35
36 // stdio
37 Command string `json:"command,omitempty"`
38 Args []string `json:"args,omitempty"`
39 Env map[string]string `json:"env,omitempty"`
40 // EnvVars names extra host variables passed through to the process in
41 // addition to the safe default allow-list.
42 EnvVars []string `json:"envVars,omitempty"`
43 CWD string `json:"cwd,omitempty"`
44
45 // http
46 URL string `json:"url,omitempty"`
47 Headers map[string]string `json:"headers,omitempty"`
48 EnvHeaders map[string]string `json:"envHeaders,omitempty"`
49 BearerTokenEnvVar string `json:"bearerTokenEnvVar,omitempty"`
50
51 // common
52 Enabled *bool `json:"enabled,omitempty"`
53 Required bool `json:"required,omitempty"`
54 EnabledTools []string `json:"enabledTools,omitempty"`
55 DisabledTools []string `json:"disabledTools,omitempty"`
56 StartupTimeoutSec float64 `json:"startupTimeoutSec,omitempty"`
57 ToolTimeoutSec float64 `json:"toolTimeoutSec,omitempty"`
58}
59
60// Config is the merged MCP configuration.
61type Config struct {
62 Servers map[string]ServerConfig `json:"mcpServers"`
63}
64
65// ConfigError reports one unusable server. Message never includes secret
66// field values.
67type ConfigError struct {
68 Server string
69 Message string
70}
71
72func (e *ConfigError) Error() string { return fmt.Sprintf("mcp server %q: %s", e.Server, e.Message) }
73
74// GlobalConfigPath is the user-level MCP configuration file.
75func GlobalConfigPath(zutHome string) string { return filepath.Join(zutHome, "mcp.json") }
76
77// ProjectConfigPath is the project-level MCP configuration file.
78func ProjectConfigPath(cwd string) string { return filepath.Join(cwd, ".zut", "mcp.json") }
79
80// LoadConfig merges the global and project files (project wins per server),
81// expands ${VAR} references, and validates each server. Invalid servers are
82// dropped and reported; a malformed file is a fatal error for that file only.
83func LoadConfig(zutHome, cwd string) (Config, []error) {
84 cfg := Config{Servers: map[string]ServerConfig{}}
85 var errs []error
86 paths := []string{}
87 if zutHome != "" {
88 paths = append(paths, GlobalConfigPath(zutHome))
89 }
90 if cwd != "" {
91 paths = append(paths, ProjectConfigPath(cwd))
92 }
93 for _, path := range paths {
94 file, err := ReadConfigFile(path)
95 if err != nil {
96 if !errors.Is(err, fs.ErrNotExist) {
97 errs = append(errs, err)
98 }
99 continue
100 }
101 for name, srv := range file.Servers {
102 cfg.Servers[name] = srv
103 }
104 }
105 for _, name := range sortedNames(cfg.Servers) {
106 srv := cfg.Servers[name]
107 if srv.Enabled != nil && !*srv.Enabled {
108 delete(cfg.Servers, name)
109 continue
110 }
111 normalized, err := normalizeServer(name, srv)
112 if err != nil {
113 delete(cfg.Servers, name)
114 errs = append(errs, err)
115 continue
116 }
117 cfg.Servers[name] = normalized
118 }
119 return cfg, errs
120}
121
122// ReadConfigFile parses one MCP JSON file without expansion or validation.
123func ReadConfigFile(path string) (Config, error) {
124 raw, err := os.ReadFile(path)
125 if err != nil {
126 return Config{}, err
127 }
128 var file Config
129 if err := json.Unmarshal(raw, &file); err != nil {
130 return Config{}, fmt.Errorf("parse %s: %w", path, err)
131 }
132 if file.Servers == nil {
133 file.Servers = map[string]ServerConfig{}
134 }
135 return file, nil
136}
137
138// WriteConfigFile atomically writes cfg to path with private permissions,
139// because entries may contain header values or environment secrets.
140func WriteConfigFile(path string, cfg Config) error {
141 if cfg.Servers == nil {
142 cfg.Servers = map[string]ServerConfig{}
143 }
144 raw, err := json.MarshalIndent(cfg, "", " ")
145 if err != nil {
146 return err
147 }
148 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
149 return err
150 }
151 tmp, err := os.CreateTemp(filepath.Dir(path), ".mcp-*.json")
152 if err != nil {
153 return err
154 }
155 tmpName := tmp.Name()
156 defer os.Remove(tmpName)
157 if _, err := tmp.Write(append(raw, '\n')); err != nil {
158 tmp.Close()
159 return err
160 }
161 if err := tmp.Chmod(0o600); err != nil {
162 tmp.Close()
163 return err
164 }
165 if err := tmp.Close(); err != nil {
166 return err
167 }
168 return os.Rename(tmpName, path)
169}
170
171// Kind returns the normalized transport, "stdio" or "http".
172func (s ServerConfig) Kind() string {
173 kind := strings.ToLower(strings.TrimSpace(firstNonEmpty(s.Type, s.Transport)))
174 switch kind {
175 case "streamable-http", "streamable_http", "streamablehttp", "http":
176 return "http"
177 case "stdio":
178 return "stdio"
179 case "":
180 if s.URL != "" && s.Command == "" {
181 return "http"
182 }
183 return "stdio"
184 default:
185 return kind
186 }
187}
188
189// StartupTimeout returns the configured or default startup timeout.
190func (s ServerConfig) StartupTimeout() time.Duration {
191 if s.StartupTimeoutSec > 0 {
192 return time.Duration(s.StartupTimeoutSec * float64(time.Second))
193 }
194 return DefaultStartupTimeout
195}
196
197// ToolTimeout returns the configured or default tools/call timeout.
198func (s ServerConfig) ToolTimeout() time.Duration {
199 if s.ToolTimeoutSec > 0 {
200 return time.Duration(s.ToolTimeoutSec * float64(time.Second))
201 }
202 return DefaultToolTimeout
203}
204
205// ToolAllowed applies enabledTools then disabledTools to a raw MCP tool name.
206func (s ServerConfig) ToolAllowed(name string) bool {
207 if len(s.EnabledTools) > 0 && !containsString(s.EnabledTools, name) {
208 return false
209 }
210 return !containsString(s.DisabledTools, name)
211}
212
213func normalizeServer(name string, srv ServerConfig) (ServerConfig, error) {
214 if strings.TrimSpace(name) == "" {
215 return srv, &ConfigError{Server: name, Message: "empty server name"}
216 }
217 if err := expandServerEnv(name, &srv); err != nil {
218 return srv, err
219 }
220 return ValidateServer(name, srv)
221}
222
223// ValidateServer checks one entry's transport shape without expanding
224// ${VAR} references, so callers can store references unexpanded.
225func ValidateServer(name string, srv ServerConfig) (ServerConfig, error) {
226 if strings.TrimSpace(name) == "" {
227 return srv, &ConfigError{Server: name, Message: "empty server name"}
228 }
229 switch srv.Kind() {
230 case "stdio":
231 if strings.TrimSpace(srv.Command) == "" {
232 return srv, &ConfigError{Server: name, Message: "stdio server requires command"}
233 }
234 if srv.URL != "" || len(srv.Headers) > 0 || len(srv.EnvHeaders) > 0 || srv.BearerTokenEnvVar != "" {
235 return srv, &ConfigError{Server: name, Message: "url, headers, envHeaders, and bearerTokenEnvVar apply only to http servers"}
236 }
237 case "http":
238 if strings.TrimSpace(srv.URL) == "" {
239 return srv, &ConfigError{Server: name, Message: "http server requires url"}
240 }
241 if !strings.HasPrefix(srv.URL, "http://") && !strings.HasPrefix(srv.URL, "https://") && !strings.HasPrefix(srv.URL, "${") {
242 return srv, &ConfigError{Server: name, Message: "url must start with http:// or https://"}
243 }
244 if srv.Command != "" || len(srv.Args) > 0 || len(srv.Env) > 0 || len(srv.EnvVars) > 0 || srv.CWD != "" {
245 return srv, &ConfigError{Server: name, Message: "command, args, env, envVars, and cwd apply only to stdio servers"}
246 }
247 case "sse":
248 return srv, &ConfigError{Server: name, Message: "legacy sse transport is not supported; use the server's streamable http endpoint"}
249 default:
250 return srv, &ConfigError{Server: name, Message: fmt.Sprintf("unknown transport %q", srv.Kind())}
251 }
252 return srv, nil
253}
254
255// envReference matches Claude Code's braced syntax only: ${VAR} and
256// ${VAR:-default}. Bare $VAR and shell expressions stay literal.
257var envReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}`)
258
259func expandServerEnv(name string, srv *ServerConfig) error {
260 var missing *ConfigError
261 expand := func(field, value string) string {
262 return envReference.ReplaceAllStringFunc(value, func(ref string) string {
263 m := envReference.FindStringSubmatch(ref)
264 if v, ok := os.LookupEnv(m[1]); ok {
265 return v
266 }
267 if m[2] != "" {
268 return m[3]
269 }
270 if missing == nil {
271 missing = &ConfigError{Server: name, Message: fmt.Sprintf("%s references unset variable %s", field, m[1])}
272 }
273 return ""
274 })
275 }
276 srv.Command = expand("command", srv.Command)
277 if len(srv.Args) > 0 {
278 args := make([]string, len(srv.Args))
279 for i, arg := range srv.Args {
280 args[i] = expand(fmt.Sprintf("args[%d]", i), arg)
281 }
282 srv.Args = args
283 }
284 srv.CWD = expand("cwd", srv.CWD)
285 srv.URL = expand("url", srv.URL)
286 srv.Env = expandMap("env", srv.Env, expand)
287 srv.Headers = expandMap("headers", srv.Headers, expand)
288 if missing != nil {
289 return missing
290 }
291 return nil
292}
293
294func expandMap(field string, in map[string]string, expand func(string, string) string) map[string]string {
295 if len(in) == 0 {
296 return in
297 }
298 out := make(map[string]string, len(in))
299 for k, v := range in {
300 out[k] = expand(field+"."+k, v)
301 }
302 return out
303}
304
305func sortedNames[T any](m map[string]T) []string {
306 names := make([]string, 0, len(m))
307 for name := range m {
308 names = append(names, name)
309 }
310 sort.Strings(names)
311 return names
312}
313
314func containsString(list []string, value string) bool {
315 for _, item := range list {
316 if item == value {
317 return true
318 }
319 }
320 return false
321}
322
323func firstNonEmpty(values ...string) string {
324 for _, v := range values {
325 if v != "" {
326 return v
327 }
328 }
329 return ""
330}