v0.51.0 a9d674e

mcpcmd.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"io/fs"
10	"os"
11	"sort"
12	"strconv"
13	"strings"
14
15	"github.com/bnema/zut/packages/agent/mcp"
16	"github.com/bnema/zut/packages/provider"
17)
18
19// runMCPCommand dispatches `zut mcp ...`. It returns handled=false when
20// rawArgs does not start with "mcp".
21func runMCPCommand(ctx context.Context, rawArgs []string, version string, out io.Writer) (bool, error) {
22	if len(rawArgs) == 0 || rawArgs[0] != "mcp" {
23		return false, nil
24	}
25	if len(rawArgs) == 1 {
26		printMCPHelp(out)
27		return true, nil
28	}
29	cwd, _ := os.Getwd()
30	rest := rawArgs[2:]
31	switch rawArgs[1] {
32	case "list", "ls":
33		return true, mcpList(out, cwd, rest)
34	case "get":
35		return true, mcpGet(out, cwd, rest)
36	case "add":
37		return true, mcpAdd(out, cwd, rest)
38	case "remove", "rm":
39		return true, mcpRemove(out, cwd, rest)
40	case "status", "test":
41		return true, mcpTest(ctx, out, cwd, version, rest)
42	case "-h", "--help", "help":
43		printMCPHelp(out)
44		return true, nil
45	default:
46		printMCPHelp(out)
47		return true, fmt.Errorf("unknown mcp subcommand %q", rawArgs[1])
48	}
49}
50
51func printMCPHelp(out io.Writer) {
52	fmt.Fprint(out, `zut mcp: manage MCP servers
53
54  zut mcp list [--json]                     list configured servers
55  zut mcp get NAME [--json]                 show one server (secret values hidden)
56  zut mcp add NAME [--project] [opts] -- COMMAND [ARGS...]
57                                            add a stdio server
58  zut mcp add NAME [--project] --url URL [opts]
59                                            add a streamable HTTP server
60  zut mcp remove NAME [--project]           remove a server
61  zut mcp status [NAME]                     connect and list tools
62
63add options:
64  --env KEY=VALUE            stdio environment variable (repeatable)
65  --env-var NAME             pass a host variable through (repeatable)
66  --header KEY=VALUE         HTTP header (repeatable)
67  --bearer-token-env-var V   send "Authorization: Bearer $V"
68  --startup-timeout SEC      startup timeout (default 30)
69  --tool-timeout SEC         tools/call timeout (default 300)
70  --required                 fail startup when this server cannot start
71
72Global servers live in $ZUT_HOME/mcp.json; --project writes .zut/mcp.json.
73`)
74}
75
76func mcpConfigPath(cwd string, project bool) string {
77	if project {
78		return mcp.ProjectConfigPath(cwd)
79	}
80	return mcp.GlobalConfigPath(ZutHome())
81}
82
83// rawMCPConfig merges the global and project files without ${VAR} expansion
84// or validation, so list/get show disabled and currently broken entries and
85// never print expanded secret values.
86func rawMCPConfig(cwd string) (mcp.Config, error) {
87	cfg := mcp.Config{Servers: map[string]mcp.ServerConfig{}}
88	var errs []error
89	for _, path := range []string{mcp.GlobalConfigPath(ZutHome()), mcp.ProjectConfigPath(cwd)} {
90		file, err := mcp.ReadConfigFile(path)
91		if err != nil {
92			if !errors.Is(err, fs.ErrNotExist) {
93				errs = append(errs, err)
94			}
95			continue
96		}
97		for name, srv := range file.Servers {
98			cfg.Servers[name] = srv
99		}
100	}
101	return cfg, errors.Join(errs...)
102}
103
104func mcpList(out io.Writer, cwd string, args []string) error {
105	asJSON := len(args) > 0 && args[0] == "--json"
106	cfg, cfgErr := rawMCPConfig(cwd)
107	if cfgErr != nil {
108		return cfgErr
109	}
110	if asJSON {
111		redacted := map[string]mcp.ServerConfig{}
112		for name, srv := range cfg.Servers {
113			redacted[name] = redactServer(srv)
114		}
115		enc := json.NewEncoder(out)
116		enc.SetIndent("", "  ")
117		return enc.Encode(map[string]any{"mcpServers": redacted})
118	}
119	names := make([]string, 0, len(cfg.Servers))
120	for name := range cfg.Servers {
121		names = append(names, name)
122	}
123	sort.Strings(names)
124	if len(names) == 0 {
125		fmt.Fprintln(out, "no MCP servers configured")
126	}
127	for _, name := range names {
128		srv := redactServer(cfg.Servers[name])
129		target := srv.URL
130		if srv.Kind() == "stdio" {
131			target = strings.TrimSpace(srv.Command + " " + strings.Join(srv.Args, " "))
132		}
133		state := ""
134		if srv.Enabled != nil && !*srv.Enabled {
135			state = "\tdisabled"
136		}
137		fmt.Fprintf(out, "%s\t%s\t%s%s\n", name, srv.Kind(), target, state)
138	}
139	return nil
140}
141
142// redactServer hides values that commonly carry secrets: env and header
143// values, URL credentials and query strings. Arguments are shown as written
144// in the file (${VAR} references stay unexpanded).
145func redactServer(srv mcp.ServerConfig) mcp.ServerConfig {
146	srv.URL = redactURL(srv.URL)
147	hide := func(m map[string]string) map[string]string {
148		if len(m) == 0 {
149			return m
150		}
151		out := make(map[string]string, len(m))
152		for k := range m {
153			out[k] = "*****"
154		}
155		return out
156	}
157	srv.Env = hide(srv.Env)
158	srv.Headers = hide(srv.Headers)
159	return srv
160}
161
162// redactURL hides userinfo, query, and fragment with string operations so an
163// unexpanded ${VAR} template that url.Parse rejects cannot fail open.
164func redactURL(raw string) string {
165	if i := strings.IndexAny(raw, "?#"); i >= 0 {
166		raw = raw[:i] + "?*****"
167	}
168	schemeEnd := strings.Index(raw, "://")
169	start := 0
170	if schemeEnd >= 0 {
171		start = schemeEnd + 3
172	}
173	authorityEnd := len(raw)
174	if i := strings.IndexByte(raw[start:], '/'); i >= 0 {
175		authorityEnd = start + i
176	}
177	if at := strings.LastIndexByte(raw[start:authorityEnd], '@'); at >= 0 {
178		raw = raw[:start] + "*****@" + raw[start+at+1:]
179	}
180	return raw
181}
182
183func mcpGet(out io.Writer, cwd string, args []string) error {
184	if len(args) == 0 {
185		return errors.New("usage: zut mcp get NAME [--json]")
186	}
187	cfg, err := rawMCPConfig(cwd)
188	if err != nil {
189		return err
190	}
191	srv, ok := cfg.Servers[args[0]]
192	if !ok {
193		return fmt.Errorf("mcp server %q is not configured", args[0])
194	}
195	enc := json.NewEncoder(out)
196	enc.SetIndent("", "  ")
197	return enc.Encode(redactServer(srv))
198}
199
200func mcpAdd(out io.Writer, cwd string, args []string) error {
201	if len(args) == 0 || strings.HasPrefix(args[0], "-") {
202		return errors.New("usage: zut mcp add NAME [options] (-- COMMAND [ARGS...] | --url URL)")
203	}
204	name := args[0]
205	var (
206		srv     mcp.ServerConfig
207		project bool
208	)
209	keyValue := func(flag, v string) (string, string, error) {
210		k, val, ok := strings.Cut(v, "=")
211		if !ok || strings.TrimSpace(k) == "" {
212			return "", "", fmt.Errorf("%s expects KEY=VALUE", flag)
213		}
214		return k, val, nil
215	}
216	rest := args[1:]
217	for i := 0; i < len(rest); i++ {
218		arg := rest[i]
219		value := func() (string, error) {
220			if i+1 >= len(rest) {
221				return "", fmt.Errorf("%s requires a value", arg)
222			}
223			i++
224			return rest[i], nil
225		}
226		var (
227			v   string
228			err error
229		)
230		switch arg {
231		case "--":
232			cmd := rest[i+1:]
233			if len(cmd) == 0 {
234				return errors.New("missing command after --")
235			}
236			srv.Command, srv.Args = cmd[0], append([]string(nil), cmd[1:]...)
237			i = len(rest)
238			continue
239		case "--project":
240			project = true
241			continue
242		case "--global":
243			project = false
244			continue
245		case "--required":
246			srv.Required = true
247			continue
248		}
249		if v, err = value(); err != nil {
250			return err
251		}
252		switch arg {
253		case "--url":
254			srv.URL = v
255		case "--env":
256			k, val, err := keyValue(arg, v)
257			if err != nil {
258				return err
259			}
260			if srv.Env == nil {
261				srv.Env = map[string]string{}
262			}
263			srv.Env[k] = val
264		case "--env-var":
265			srv.EnvVars = append(srv.EnvVars, v)
266		case "--header":
267			k, val, err := keyValue(arg, v)
268			if err != nil {
269				return err
270			}
271			if srv.Headers == nil {
272				srv.Headers = map[string]string{}
273			}
274			srv.Headers[k] = val
275		case "--bearer-token-env-var":
276			srv.BearerTokenEnvVar = v
277		case "--startup-timeout", "--tool-timeout":
278			sec, err := strconv.ParseFloat(v, 64)
279			if err != nil || sec <= 0 {
280				return fmt.Errorf("%s expects a positive number of seconds", arg)
281			}
282			if arg == "--startup-timeout" {
283				srv.StartupTimeoutSec = sec
284			} else {
285				srv.ToolTimeoutSec = sec
286			}
287		default:
288			return fmt.Errorf("unknown option %q", arg)
289		}
290	}
291	if (srv.Command == "") == (srv.URL == "") {
292		return errors.New("give exactly one of -- COMMAND or --url URL")
293	}
294	if srv.URL != "" {
295		srv.Type = "http"
296	}
297	if _, err := mcp.ValidateServer(name, srv); err != nil {
298		return err
299	}
300	path := mcpConfigPath(cwd, project)
301	file, err := mcp.ReadConfigFile(path)
302	if errors.Is(err, fs.ErrNotExist) {
303		file, err = mcp.Config{Servers: map[string]mcp.ServerConfig{}}, nil
304	}
305	if err != nil {
306		return err
307	}
308	_, existed := file.Servers[name]
309	file.Servers[name] = srv
310	if err := mcp.WriteConfigFile(path, file); err != nil {
311		return err
312	}
313	verb := "added"
314	if existed {
315		verb = "updated"
316	}
317	fmt.Fprintf(out, "%s mcp server %q in %s\n", verb, name, path)
318	return nil
319}
320
321func mcpRemove(out io.Writer, cwd string, args []string) error {
322	if len(args) == 0 {
323		return errors.New("usage: zut mcp remove NAME [--project]")
324	}
325	project := len(args) > 1 && args[1] == "--project"
326	path := mcpConfigPath(cwd, project)
327	file, err := mcp.ReadConfigFile(path)
328	if err != nil {
329		if errors.Is(err, fs.ErrNotExist) {
330			return fmt.Errorf("mcp server %q not found in %s", args[0], path)
331		}
332		return err
333	}
334	if _, ok := file.Servers[args[0]]; !ok {
335		return fmt.Errorf("mcp server %q not found in %s", args[0], path)
336	}
337	delete(file.Servers, args[0])
338	if err := mcp.WriteConfigFile(path, file); err != nil {
339		return err
340	}
341	fmt.Fprintf(out, "removed mcp server %q from %s\n", args[0], path)
342	return nil
343}
344
345func mcpTest(ctx context.Context, out io.Writer, cwd, version string, args []string) error {
346	cfg, errs := mcp.LoadConfig(ZutHome(), cwd)
347	for _, err := range errs {
348		fmt.Fprintln(os.Stderr, "mcp config error:", err)
349	}
350	if len(args) > 0 {
351		srv, ok := cfg.Servers[args[0]]
352		if !ok {
353			return fmt.Errorf("mcp server %q is not configured, disabled, or invalid", args[0])
354		}
355		cfg.Servers = map[string]mcp.ServerConfig{args[0]: srv}
356	}
357	manager := mcp.NewManager(cfg, cwd, mcp.ClientInfo{Name: "zut", Version: version}, provider.NewHTTPClient(false))
358	defer manager.Close()
359	failures, _ := manager.Start(ctx)
360	fmt.Fprintln(out, mcp.FormatStatus(manager.Status()))
361	// Names come from the registry builder so collision suffixes match what
362	// --tools and the model see.
363	for _, tool := range manager.Tools() {
364		fmt.Fprintf(out, "  %s\n", tool.Name())
365	}
366	if n := len(failures) + len(errs); n > 0 {
367		return fmt.Errorf("%d MCP configuration or startup problem(s)", n)
368	}
369	return nil
370}

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 476

↑↓ moveenter openesc close