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}