v0.3.57 169b025

bridge.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close