v0.42.0 82578de

bridge.go RawHistoryFolder

1// bridge.go — MCP tool → zut tool registration and routing.
2//
3// Converts MCP tools into zut-registered tools with namespaced names:
4//
5//	mcp__<server>__<tool>
6//
7// The double underscore separates the server name from the tool name,
8// avoiding collisions with zut's built-in tools (read, write, edit, bash,
9// worktree, grep, lsp, web_search, skill). The web_search and grep
10// names are reserved even when those built-ins are disabled.
11package main
12
13import (
14	"context"
15	"encoding/json"
16	"errors"
17	"fmt"
18	"log"
19	"regexp"
20	"sort"
21	"strings"
22	"sync"
23	"time"
24
25	"github.com/bnema/zut/packages/agent/ext"
26	"github.com/mark3labs/mcp-go/mcp"
27)
28
29const (
30	mcpSearchToolName      = "mcp__search_tools"
31	defaultToolSearchLimit = 8
32	maxToolSearchLimit     = 20
33)
34
35var mcpSearchToolSchema = json.RawMessage(`{
36  "type": "object",
37  "properties": {
38    "query": {
39      "type": "string",
40      "description": "Words describing the MCP capability needed, such as 'search GitHub code' or 'list n8n workflows'."
41    },
42    "limit": {
43      "type": "integer",
44      "minimum": 1,
45      "maximum": 20,
46      "description": "Maximum number of matching tools to load. Defaults to 8."
47    }
48  },
49  "required": ["query"],
50  "additionalProperties": false
51}`)
52
53// toolMapping tracks which zut tool name maps to which MCP server + tool.
54type toolMapping struct {
55	serverName  string // e.g. "filesystem"
56	mcpTool     string // e.g. "read_file"
57	description string
58}
59
60// bridge connects MCP servers to zut's extension protocol.
61type bridge struct {
62	e   *ext.Extension
63	cwd string
64	// servers is written once in loadServers before any goroutine
65	// starts, and read-only afterwards — that invariant is what makes
66	// the lock-free reads in handleToolCall and the idle reaper safe.
67	// Do not add or remove entries after startup.
68	servers map[string]*managedServer
69	mapping map[string]toolMapping // zut tool name → MCP server + tool; guarded by mu
70	logger  *log.Logger
71
72	mu               sync.Mutex // guards mapping and searchRegistered
73	searchRegistered bool
74}
75
76// newBridge creates a new MCP→zut bridge.
77func newBridge(e *ext.Extension, cwd string, logger *log.Logger) *bridge {
78	return &bridge{
79		e:       e,
80		cwd:     cwd,
81		servers: make(map[string]*managedServer),
82		mapping: make(map[string]toolMapping),
83		logger:  logger,
84	}
85}
86
87// sanitizeName converts a string into a valid zut tool name component.
88// Zut tool names must match [a-zA-Z][a-zA-Z0-9_]*.
89var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]`)
90
91func sanitizeName(s string) string {
92	s = invalidChars.ReplaceAllString(s, "_")
93	// Ensure it starts with a letter
94	if len(s) > 0 && (s[0] >= '0' && s[0] <= '9') {
95		s = "t_" + s
96	}
97	if s == "" {
98		s = "unnamed"
99	}
100	return s
101}
102
103// toolName builds the namespaced zut tool name for an MCP tool.
104func toolName(serverName, mcpToolName string) string {
105	return fmt.Sprintf("mcp__%s__%s", sanitizeName(serverName), sanitizeName(mcpToolName))
106}
107
108// loadServers reads the config and creates managed servers.
109func (b *bridge) loadServers(cfg Config) {
110	for name, srvCfg := range cfg.MCPServers {
111		srv := newManagedServer(name, srvCfg, b.cwd, b.logger)
112		b.servers[name] = srv
113	}
114}
115
116// registerToolSearch exposes the only eager MCP tool definition. The actual
117// MCP tools stay deferred until this local catalog search activates a small,
118// relevant subset. This keeps large MCP installations from bloating every LLM
119// request and is required by providers with tight tool-schema size limits.
120func (b *bridge) registerToolSearch() {
121	b.mu.Lock()
122	if b.searchRegistered {
123		b.mu.Unlock()
124		return
125	}
126	b.searchRegistered = true
127	b.mu.Unlock()
128
129	b.e.Tool(mcpSearchToolName,
130		"Search configured MCP tools by capability and load the matching tool definitions. Use this before calling an MCP tool that is not already available.",
131		mcpSearchToolSchema,
132		b.searchTools,
133	)
134}
135
136// registerCachedTools registers previously discovered tool definitions without
137// starting MCP servers. Tool calls still lazy-start the owning server on demand.
138func (b *bridge) registerCachedTools(cache toolCache) int {
139	b.registerToolSearch()
140	count := 0
141	for serverName, srv := range b.servers {
142		cached, ok := cache.Servers[serverName]
143		if !ok || cached.Fingerprint != serverFingerprint(srv.config) {
144			continue
145		}
146		cachedNames := make([]mcp.Tool, 0, len(cached.Tools))
147		for _, tool := range cached.Tools {
148			b.registerCachedTool(serverName, tool)
149			cachedNames = append(cachedNames, mcp.Tool{Name: tool.Name})
150			count++
151		}
152		srv.mu.Lock()
153		if len(srv.tools) == 0 {
154			srv.tools = cachedNames
155		}
156		srv.mu.Unlock()
157	}
158	return count
159}
160
161// refreshToolCache discovers live tools and updates the on-disk cache.
162// It reports whether the cache changed; callers should only ask for /reload-ext
163// when changed is true.
164func (b *bridge) refreshToolCache(ctx context.Context, path string) (bool, error) {
165	previous, err := readToolCache(path)
166	if err != nil {
167		b.logger.Printf("read existing tool cache: %v", err)
168	}
169	cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
170	var wg sync.WaitGroup
171	errCh := make(chan error, len(b.servers))
172	var mu sync.Mutex
173
174	for name, srv := range b.servers {
175		wg.Add(1)
176		go func(n string, s *managedServer) {
177			defer wg.Done()
178			if err := s.start(ctx); err != nil {
179				b.logger.Printf("[%s] lazy discovery failed: %v", n, err)
180				errCh <- fmt.Errorf("%s: %w", n, err)
181				return
182			}
183
184			s.mu.Lock()
185			tools := append([]mcp.Tool(nil), s.tools...)
186			s.mu.Unlock()
187
188			cachedTools := make([]cachedTool, 0, len(tools))
189			for _, tool := range tools {
190				ct, err := cachedToolFromMCP(n, tool)
191				if err != nil {
192					b.logger.Printf("[%s] tool %s: schema marshal error: %v", n, tool.Name, err)
193					continue
194				}
195				cachedTools = append(cachedTools, ct)
196			}
197
198			sort.Slice(cachedTools, func(i, j int) bool { return cachedTools[i].Name < cachedTools[j].Name })
199
200			mu.Lock()
201			cache.Servers[n] = cachedServer{Fingerprint: serverFingerprint(s.config), Tools: cachedTools}
202			mu.Unlock()
203		}(name, srv)
204	}
205
206	wg.Wait()
207	close(errCh)
208
209	changed := !toolCachesEqual(previous, cache)
210	if changed {
211		if err := writeToolCache(path, cache); err != nil {
212			return false, err
213		}
214	}
215
216	var errs []string
217	for err := range errCh {
218		errs = append(errs, err.Error())
219	}
220	if len(errs) > 0 {
221		return changed, fmt.Errorf("%s", strings.Join(errs, "; "))
222	}
223	return changed, nil
224}
225
226func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
227	zutName := toolName(serverName, tool.Name)
228
229	b.mu.Lock()
230	if existing, exists := b.mapping[zutName]; exists {
231		b.mu.Unlock()
232		if existing.serverName != serverName || existing.mcpTool != tool.Name {
233			b.logger.Printf("tool name collision: %s already maps to %s/%s; skipping %s/%s",
234				zutName, existing.serverName, existing.mcpTool, serverName, tool.Name)
235		}
236		return
237	}
238	b.mapping[zutName] = toolMapping{
239		serverName:  serverName,
240		mcpTool:     tool.Name,
241		description: tool.Description,
242	}
243	b.mu.Unlock()
244
245	b.e.DeferredTool(zutName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
246		return b.handleToolCall(zutName, args)
247	})
248
249	b.logger.Printf("registered deferred cached tool: %s → %s/%s", zutName, serverName, tool.Name)
250}
251
252type toolSearchArgs struct {
253	Query string `json:"query"`
254	Limit int    `json:"limit,omitempty"`
255}
256
257type toolSearchMatch struct {
258	name        string
259	description string
260	score       int
261}
262
263func (b *bridge) searchTools(raw json.RawMessage) ext.ToolResult {
264	var args toolSearchArgs
265	if err := json.Unmarshal(raw, &args); err != nil {
266		return ext.TextErrorResult(mcpSearchToolName + ": invalid arguments: " + err.Error())
267	}
268	query := strings.TrimSpace(args.Query)
269	if query == "" {
270		return ext.TextResult("No MCP tools loaded: provide a non-empty capability query.")
271	}
272	limit := args.Limit
273	if limit == 0 {
274		limit = defaultToolSearchLimit
275	}
276	if limit < 1 || limit > maxToolSearchLimit {
277		return ext.TextErrorResult(fmt.Sprintf("%s: limit must be between 1 and %d", mcpSearchToolName, maxToolSearchLimit))
278	}
279
280	matches := b.findTools(query, limit)
281	if len(matches) == 0 {
282		return ext.TextResult(fmt.Sprintf("No MCP tools matched %q. Try broader capability words or an MCP server name.", query))
283	}
284
285	var text strings.Builder
286	fmt.Fprintf(&text, "Loaded %d MCP tool(s) matching %q:\n", len(matches), query)
287	activated := make([]string, 0, len(matches))
288	for _, match := range matches {
289		activated = append(activated, match.name)
290		fmt.Fprintf(&text, "- %s", match.name)
291		if match.description != "" {
292			fmt.Fprintf(&text, ": %s", match.description)
293		}
294		text.WriteByte('\n')
295	}
296	text.WriteString("Call the appropriate loaded tool now. Search again if none fits the task.")
297	return ext.ToolResult{
298		Content:       []ext.ToolContent{ext.Text(text.String())},
299		ActivateTools: activated,
300	}
301}
302
303func (b *bridge) findTools(query string, limit int) []toolSearchMatch {
304	terms := strings.Fields(strings.ToLower(query))
305	if len(terms) == 0 || limit <= 0 {
306		return nil
307	}
308
309	b.mu.Lock()
310	matches := make([]toolSearchMatch, 0, len(b.mapping))
311	for name, mapping := range b.mapping {
312		score := scoreToolMatch(terms, name, mapping)
313		if score > 0 {
314			matches = append(matches, toolSearchMatch{name: name, description: mapping.description, score: score})
315		}
316	}
317	b.mu.Unlock()
318
319	sort.Slice(matches, func(i, j int) bool {
320		if matches[i].score != matches[j].score {
321			return matches[i].score > matches[j].score
322		}
323		return matches[i].name < matches[j].name
324	})
325	if len(matches) > limit {
326		matches = matches[:limit]
327	}
328	return matches
329}
330
331func scoreToolMatch(terms []string, zutName string, mapping toolMapping) int {
332	name := strings.ToLower(zutName)
333	server := strings.ToLower(mapping.serverName)
334	mcpName := strings.ToLower(mapping.mcpTool)
335	description := strings.ToLower(mapping.description)
336	score := 0
337	matched := 0
338	for _, term := range terms {
339		termScore := 0
340		switch {
341		case term == mcpName || term == name:
342			termScore = 100
343		case strings.Contains(mcpName, term):
344			termScore = 60
345		case strings.Contains(server, term):
346			termScore = 40
347		case strings.Contains(name, term):
348			termScore = 30
349		case strings.Contains(description, term):
350			termScore = 15
351		}
352		if termScore > 0 {
353			matched++
354			score += termScore
355		}
356	}
357	if matched == len(terms) {
358		score += 25
359	}
360	return score
361}
362
363func mcpToolDescription(serverName string, tool mcp.Tool) string {
364	desc := tool.Description
365	if tool.Annotations.Title != "" {
366		desc = tool.Annotations.Title + ": " + desc
367	}
368
369	var hints []string
370	if tool.Annotations.ReadOnlyHint != nil && *tool.Annotations.ReadOnlyHint {
371		hints = append(hints, "read-only")
372	}
373	if tool.Annotations.IdempotentHint != nil && *tool.Annotations.IdempotentHint {
374		hints = append(hints, "idempotent")
375	}
376	if tool.Annotations.OpenWorldHint != nil && !*tool.Annotations.OpenWorldHint {
377		hints = append(hints, "closed-world")
378	}
379	if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
380		hints = append(hints, "destructive")
381	}
382	if len(hints) > 0 {
383		desc += " [" + strings.Join(hints, ", ") + "]"
384	}
385	if desc == "" {
386		desc = fmt.Sprintf("MCP tool from server %q", serverName)
387	}
388	return desc
389}
390
391// mcpToolSchema converts an MCP Tool's input schema to a JSON Schema value.
392func mcpToolSchema(tool mcp.Tool) any {
393	if len(tool.RawInputSchema) > 0 {
394		return json.RawMessage(tool.RawInputSchema)
395	}
396
397	schema := map[string]any{
398		"type":       tool.InputSchema.Type,
399		"properties": tool.InputSchema.Properties,
400	}
401	if len(tool.InputSchema.Required) > 0 {
402		schema["required"] = tool.InputSchema.Required
403	}
404	if len(tool.InputSchema.Defs) > 0 {
405		schema["$defs"] = tool.InputSchema.Defs
406	}
407	if tool.InputSchema.AdditionalProperties != nil {
408		schema["additionalProperties"] = tool.InputSchema.AdditionalProperties
409	}
410	return schema
411}
412
413// handleToolCall routes a zut tool call to the appropriate MCP server.
414func (b *bridge) handleToolCall(zutName string, args json.RawMessage) ext.ToolResult {
415	b.mu.Lock()
416	mapping, ok := b.mapping[zutName]
417	b.mu.Unlock()
418
419	if !ok {
420		return ext.TextErrorResult(fmt.Sprintf(
421			"Tool '%s' not found. This tool was registered but is no longer available. "+
422				"The MCP server may have been stopped. Try running '/mcp' to check server status.",
423			zutName))
424	}
425
426	srv, ok := b.servers[mapping.serverName]
427	if !ok {
428		return ext.TextErrorResult(fmt.Sprintf(
429			"MCP server '%s' not found. The server configuration may have been removed. "+
430				"Check your mcp.json configuration file.",
431			mapping.serverName))
432	}
433
434	// managedServer owns all timeouts (connect + request); pass a
435	// plain context here.
436	result, err := srv.callTool(context.Background(), mapping.mcpTool, args)
437	if err != nil {
438		if errors.Is(err, context.DeadlineExceeded) {
439			return ext.TextErrorResult(timeoutErrorMessage(mapping.serverName, srv.config.RequestTimeout))
440		}
441		errMsg := err.Error()
442		if strings.Contains(errMsg, "connection") || strings.Contains(errMsg, "transport") {
443			return ext.TextErrorResult(fmt.Sprintf(
444				"Connection to MCP server '%s' failed: %v. The server may have crashed or been stopped. "+
445					"Try running '/mcp start %s' to restart that server, or '/mcp restart' to restart all servers.",
446				mapping.serverName, err, mapping.serverName))
447		}
448		return ext.TextErrorResult(fmt.Sprintf(
449			"MCP tool call failed: %v. Server: %s, Tool: %s. "+
450				"Check '/mcp %s' for server status.",
451			err, mapping.serverName, mapping.mcpTool, mapping.serverName))
452	}
453
454	// Convert MCP result to zut ToolResult
455	return mcpResultToZut(result)
456}
457
458// timeoutErrorMessage formats the user-facing text for a deadline error.
459func timeoutErrorMessage(serverName string, requestTimeout int) string {
460	return fmt.Sprintf(
461		"Tool call timed out after %d seconds. The MCP server '%s' may be slow or unresponsive. "+
462			"You can increase the timeout in your mcp.json config with 'requestTimeout'.",
463		requestTimeout, serverName)
464}
465
466// mcpResultToZut converts an MCP CallToolResult to a zut ToolResult.
467func mcpResultToZut(result *mcp.CallToolResult) ext.ToolResult {
468	if result == nil {
469		return ext.TextResult("(no result)")
470	}
471
472	var contents []ext.ToolContent
473	for _, c := range result.Content {
474		switch v := c.(type) {
475		case mcp.TextContent:
476			contents = append(contents, ext.Text(v.Text))
477		case mcp.ImageContent:
478			contents = append(contents, ext.Image(v.MIMEType, v.Data))
479		default:
480			// Fallback: try to marshal as JSON
481			data, _ := json.Marshal(v)
482			contents = append(contents, ext.Text(string(data)))
483		}
484	}
485
486	if len(contents) == 0 {
487		contents = append(contents, ext.Text("(empty result)"))
488	}
489
490	tr := ext.ToolResult{Content: contents}
491	if result.IsError {
492		tr.IsError = true
493	}
494	return tr
495}
496
497// startAll starts all configured servers without re-registering their tools with zut.
498func (b *bridge) startAll(ctx context.Context) error {
499	var wg sync.WaitGroup
500	errCh := make(chan error, len(b.servers))
501
502	for name, srv := range b.servers {
503		wg.Add(1)
504		go func(n string, s *managedServer) {
505			defer wg.Done()
506			if err := s.start(ctx); err != nil {
507				b.logger.Printf("[%s] failed to start: %v", n, err)
508				errCh <- fmt.Errorf("%s: %w", n, err)
509			}
510		}(name, srv)
511	}
512
513	wg.Wait()
514	close(errCh)
515
516	var errs []string
517	for err := range errCh {
518		errs = append(errs, err.Error())
519	}
520	if len(errs) > 0 {
521		return fmt.Errorf("%s", strings.Join(errs, "; "))
522	}
523	return nil
524}
525
526// startIdleReaper runs a background goroutine that kills idle servers.
527// It has no shutdown mechanism by design: it lives exactly as long as
528// the extension process, and zut reaps the whole process on exit.
529func (b *bridge) startIdleReaper() {
530	go func() {
531		ticker := time.NewTicker(30 * time.Second)
532		defer ticker.Stop()
533		for range ticker.C {
534			for _, srv := range b.servers {
535				// Use per-server idle timeout
536				idleTimeout := time.Duration(srv.config.IdleTimeout) * time.Second
537				if srv.isIdle(idleTimeout) {
538					b.logger.Printf("[%s] idle timeout, stopping", srv.name)
539					srv.stop()
540				}
541			}
542		}
543	}()
544}
545
546// stopAll shuts down all MCP servers.
547func (b *bridge) stopAll() {
548	for _, srv := range b.servers {
549		srv.stop()
550	}
551}
552
553// serverStatus returns compact status info for all servers in stable order.
554func (b *bridge) serverStatus() []string {
555	names := make([]string, 0, len(b.servers))
556	for name := range b.servers {
557		names = append(names, name)
558	}
559	sort.Strings(names)
560
561	lines := make([]string, 0, len(names))
562	for _, name := range names {
563		lines = append(lines, b.servers[name].status())
564	}
565	return lines
566}
567
568func (b *bridge) notifyLevel() string {
569	if len(b.servers) == 0 {
570		return "info"
571	}
572	ready := 0
573	errored := 0
574	for _, srv := range b.servers {
575		srv.mu.Lock()
576		state := srv.state
577		srv.mu.Unlock()
578		switch state {
579		case stateReady:
580			ready++
581		case stateError:
582			errored++
583		}
584	}
585	if errored > 0 && ready == 0 {
586		return "error"
587	}
588	if errored > 0 {
589		return "warn"
590	}
591	return "success"
592}
593
594// startServer manually starts a specific server.
595func (b *bridge) startServer(name string) error {
596	srv, ok := b.servers[name]
597	if !ok {
598		return fmt.Errorf("unknown server: %s", name)
599	}
600	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
601	defer cancel()
602	return srv.start(ctx)
603}
604
605// stopServer manually stops a specific server.
606func (b *bridge) stopServer(name string) error {
607	srv, ok := b.servers[name]
608	if !ok {
609		return fmt.Errorf("unknown server: %s", name)
610	}
611	srv.stop()
612	return nil
613}

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