1package agent
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/signal"
8 "strings"
9 "syscall"
10
11 "github.com/bnema/zut/packages/agent/mcp"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16// mcpToolSelector is the --tools entry that enables every MCP tool.
17const mcpToolSelector = "mcp"
18
19// mcpAllowed reports whether MCP servers may start for args. Packaged agents
20// (PermissionSet) fail closed because their manifest cannot describe what an
21// external MCP server may do.
22func mcpAllowed(args Args) bool {
23 if args.NoMCP || args.NoTools || args.PermissionSet != nil {
24 return false
25 }
26 if args.ToolsSet && len(args.Tools) == 0 {
27 return false
28 }
29 if len(args.Tools) == 0 {
30 return true
31 }
32 for _, name := range args.Tools {
33 if name == mcpToolSelector || mcp.IsMCPToolName(name) {
34 return true
35 }
36 }
37 return false
38}
39
40// startMCP loads the MCP configuration and connects every server once per
41// process. The manager is stored in args so every Resolve (primary, reloads,
42// and resident children) shares the same connections. A server marked
43// required that fails to start aborts the run.
44func startMCP(ctx context.Context, args *Args, version string) (func(), error) {
45 noop := func() {}
46 if args.mcpManager != nil || !mcpAllowed(*args) {
47 return noop, nil
48 }
49 cfg, cfgErrs := mcp.LoadConfig(ZutHome(), args.CWD)
50 for _, err := range cfgErrs {
51 args.mcpStartupErrors = append(args.mcpStartupErrors, err.Error())
52 }
53 if len(cfg.Servers) == 0 {
54 reportMCPStartup(*args)
55 return noop, nil
56 }
57 // MCP endpoints always verify TLS; --insecure is scoped to inference endpoints.
58 manager := mcp.NewManager(cfg, args.CWD, mcp.ClientInfo{Name: "zut", Version: version}, provider.NewHTTPClient(false))
59 sigCh := make(chan os.Signal, 1)
60 signal.Notify(sigCh, mcpSignals...)
61 failures, sig, requiredErr := startMCPInterruptible(ctx, manager, sigCh)
62 signal.Stop(sigCh)
63 if sig != nil {
64 // Interrupted during startup: no mode owns signals yet, so stop the
65 // servers already launched and terminate like an unhandled signal.
66 _ = manager.Close()
67 mcpSignalExit(signalExitCode(sig))
68 return noop, fmt.Errorf("mcp startup interrupted by %v", sig)
69 }
70 for _, err := range failures {
71 args.mcpStartupErrors = append(args.mcpStartupErrors, err.Error())
72 }
73 if requiredErr != nil {
74 _ = manager.Close()
75 return noop, fmt.Errorf("required MCP server failed to start: %w", requiredErr)
76 }
77 args.mcpManager = manager
78 reportMCPStartup(*args)
79 return func() { _ = manager.Close() }, nil
80}
81
82var mcpSignals = []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGHUP}
83
84// mcpSignalExit terminates the process after a signal; tests replace it.
85var mcpSignalExit = os.Exit
86
87func signalExitCode(sig os.Signal) int {
88 if s, ok := sig.(syscall.Signal); ok {
89 return 128 + int(s)
90 }
91 return 1
92}
93
94// startMCPInterruptible runs manager.Start and cancels the handshakes when a
95// signal arrives on sigCh, waiting until every connection attempt has closed
96// its transport. It returns the signal that interrupted startup, if any.
97func startMCPInterruptible(ctx context.Context, manager *mcp.Manager, sigCh <-chan os.Signal) ([]error, os.Signal, error) {
98 startCtx, cancel := context.WithCancel(ctx)
99 defer cancel()
100 type result struct {
101 failures []error
102 required error
103 }
104 done := make(chan result, 1)
105 go func() {
106 failures, required := manager.Start(startCtx)
107 done <- result{failures, required}
108 }()
109 select {
110 case r := <-done:
111 return r.failures, nil, r.required
112 case sig := <-sigCh:
113 cancel()
114 <-done
115 return nil, sig, nil
116 }
117}
118
119// exitAfterMCPCleanupOnSignal keeps the default "terminate on signal"
120// behavior of headless modes while closing MCP servers first. Default
121// handling is restored before cleanup, so a second signal still kills zut
122// immediately. The returned func uninstalls the handler.
123func exitAfterMCPCleanupOnSignal(manager *mcp.Manager) func() {
124 sigCh := make(chan os.Signal, 1)
125 done := make(chan struct{})
126 signal.Notify(sigCh, mcpSignals...)
127 go func() {
128 select {
129 case sig := <-sigCh:
130 signal.Stop(sigCh)
131 _ = manager.Close()
132 mcpSignalExit(signalExitCode(sig))
133 case <-done:
134 }
135 }()
136 return func() {
137 signal.Stop(sigCh)
138 close(done)
139 }
140}
141
142// reportMCPStartup prints startup failures for non-interactive modes. The
143// interactive TUI shows them with the extension startup errors instead.
144func reportMCPStartup(args Args) {
145 if args.Mode == "" || args.Mode == ModeInteractive {
146 return
147 }
148 for _, msg := range args.mcpStartupErrors {
149 fmt.Fprintln(os.Stderr, "mcp:", msg)
150 }
151}
152
153// mcpToolSelected applies an explicit --tools list to one MCP tool name.
154func mcpToolSelected(args Args, name string) bool {
155 if len(args.Tools) == 0 {
156 return true
157 }
158 return toolListContains(args.Tools, mcpToolSelector) || toolListContains(args.Tools, name)
159}
160
161// addMCPTools registers the shared manager's tools in reg. The strict
162// headless orchestrator is manager-only, so it receives only tools the
163// server marks read-only (plus resource reads) for research; its workers
164// receive the full set.
165func addMCPTools(reg core.Registry, args Args) {
166 if reg == nil || args.mcpManager == nil || !mcpAllowed(args) {
167 return
168 }
169 strictOrchestrator := args.Orchestrate && !args.ResidentChild
170 for _, tool := range args.mcpManager.Tools() {
171 name := tool.Name()
172 if _, exists := reg[name]; exists || !mcpToolSelected(args, name) {
173 continue
174 }
175 if strictOrchestrator && !mcp.IsReadOnlyTool(tool) {
176 continue
177 }
178 reg[name] = tool
179 }
180}
181
182// mcpSystemAddendum describes connected servers when their tools are present.
183func mcpSystemAddendum(reg core.Registry, args Args) string {
184 if args.mcpManager == nil {
185 return ""
186 }
187 has := false
188 for _, tool := range reg {
189 if mcp.IsTool(tool) {
190 has = true
191 break
192 }
193 }
194 strictOrchestrator := args.Orchestrate && !args.ResidentChild
195 if !has && !strictOrchestrator {
196 return ""
197 }
198 addendum := args.mcpManager.SystemPromptAddendum()
199 if addendum == "" {
200 return ""
201 }
202 if args.Orchestrate && !args.ResidentChild {
203 addendum += "\nAs the orchestrator you only hold read-only MCP tools for research. Workers receive every MCP tool; delegate MCP actions that change external state."
204 }
205 return addendum
206}
207
208// childMCPTools returns MCP tool names available to a resident child even
209// when the manager-only parent does not hold them.
210func childMCPTools(child core.Registry) []string {
211 var out []string
212 for name, tool := range child {
213 if mcp.IsTool(tool) {
214 out = append(out, name)
215 }
216 }
217 return out
218}
219
220// mcpStatusText renders /mcp and `zut mcp status` output.
221func mcpStatusText(args Args) string {
222 if args.mcpManager == nil {
223 if !mcpAllowed(args) {
224 return "MCP is disabled for this run"
225 }
226 if len(args.mcpStartupErrors) > 0 {
227 return "MCP startup problems:\n" + strings.Join(args.mcpStartupErrors, "\n")
228 }
229 return "no MCP servers configured (" + mcp.GlobalConfigPath(ZutHome()) + " or .zut/mcp.json)"
230 }
231 return mcp.FormatStatus(args.mcpManager.Status())
232}