v0.50.1 a637010

mcp_runtime.go RawHistoryFolder

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}

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