v0.3.92 6d56e22

config.go RawHistoryFolder

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}

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