main 4509ec7

config.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close