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 "runtime"
20)
21
22// ServerConfig describes one MCP server entry.
23type ServerConfig struct {
24 // Stdio transport fields
25 Command string `json:"command,omitempty"` // executable to spawn
26 Args []string `json:"args,omitempty"` // arguments
27 Env map[string]string `json:"env,omitempty"` // extra env vars
28
29 // HTTP transport fields
30 Transport string `json:"transport,omitempty"` // "stdio" (default) | "streamable-http" | "sse"
31 URL string `json:"url,omitempty"` // server URL for HTTP transports
32 Headers map[string]string `json:"headers,omitempty"` // custom HTTP headers
33
34 // Timeouts (in seconds)
35 ConnectTimeout int `json:"connectTimeout,omitempty"` // connection timeout (default: 30)
36 RequestTimeout int `json:"requestTimeout,omitempty"` // per-request timeout (default: 60)
37 IdleTimeout int `json:"idleTimeout,omitempty"` // idle timeout before stopping (default: 300)
38}
39
40// Config is the top-level MCP configuration.
41type Config struct {
42 MCPServers map[string]ServerConfig `json:"mcpServers"`
43}
44
45// zotHome returns the zot state directory.
46func zotHome() string {
47 if h := os.Getenv("ZOT_HOME"); h != "" {
48 return h
49 }
50 if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
51 return filepath.Join(xdg, "zot")
52 }
53 switch runtime.GOOS {
54 case "darwin":
55 home, _ := os.UserHomeDir()
56 return filepath.Join(home, "Library", "Application Support", "zot")
57 case "windows":
58 return filepath.Join(os.Getenv("APPDATA"), "zot")
59 default: // linux, freebsd, etc.
60 home, _ := os.UserHomeDir()
61 return filepath.Join(home, ".local", "state", "zot")
62 }
63}
64
65// loadConfig reads and merges global + project MCP configs.
66// cwd is the current working directory (for project config lookup).
67func loadConfig(cwd string) (Config, error) {
68 cfg := Config{MCPServers: make(map[string]ServerConfig)}
69
70 // 1. Global config
71 globalPath := filepath.Join(zotHome(), "mcp.json")
72 if err := mergeConfig(&cfg, globalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
73 return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
74 }
75
76 // 2. Project config (overrides global per-server)
77 if cwd != "" {
78 projectPath := filepath.Join(cwd, ".zot", "mcp.json")
79 if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
80 return cfg, fmt.Errorf("project config %s: %w", projectPath, err)
81 }
82 }
83
84 return cfg, nil
85}
86
87// mergeConfig reads a JSON config file and merges its servers into cfg.
88func mergeConfig(cfg *Config, path string) error {
89 data, err := os.ReadFile(path)
90 if err != nil {
91 return err
92 }
93 var file Config
94 if err := json.Unmarshal(data, &file); err != nil {
95 return fmt.Errorf("parse %s: %w", path, err)
96 }
97 for name, srv := range file.MCPServers {
98 // Apply defaults
99 if srv.Transport == "" {
100 srv.Transport = "stdio"
101 }
102 if srv.ConnectTimeout == 0 {
103 srv.ConnectTimeout = 30
104 }
105 if srv.RequestTimeout == 0 {
106 srv.RequestTimeout = 60
107 }
108 if srv.IdleTimeout == 0 {
109 srv.IdleTimeout = 300
110 }
111 cfg.MCPServers[name] = srv
112 }
113 return nil
114}