v0.2.91 4d6e4be

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
27// toolMapping tracks which zot tool name maps to which MCP server + tool.
28type toolMapping struct {
29	serverName string // e.g. "filesystem"
30	mcpTool    string // e.g. "read_file"
31}
32
33// bridge connects MCP servers to zot's extension protocol.
34type bridge struct {
35	e   *ext.Extension
36	cwd string
37	// servers is written once in loadServers before any goroutine
38	// starts, and read-only afterwards — that invariant is what makes
39	// the lock-free reads in handleToolCall and the idle reaper safe.
40	// Do not add or remove entries after startup.
41	servers map[string]*managedServer
42	mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
43	logger  *log.Logger
44
45	mu sync.Mutex // guards mapping
46}
47
48// newBridge creates a new MCP→zot bridge.
49func newBridge(e *ext.Extension, cwd string, logger *log.Logger) *bridge {
50	return &bridge{
51		e:       e,
52		cwd:     cwd,
53		servers: make(map[string]*managedServer),
54		mapping: make(map[string]toolMapping),
55		logger:  logger,
56	}
57}
58
59// sanitizeName converts a string into a valid zot tool name component.
60// Zot tool names must match [a-zA-Z][a-zA-Z0-9_]*.
61var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]`)
62
63func sanitizeName(s string) string {
64	s = invalidChars.ReplaceAllString(s, "_")
65	// Ensure it starts with a letter
66	if len(s) > 0 && (s[0] >= '0' && s[0] <= '9') {
67		s = "t_" + s
68	}
69	if s == "" {
70		s = "unnamed"
71	}
72	return s
73}
74
75// toolName builds the namespaced zot tool name for an MCP tool.
76func toolName(serverName, mcpToolName string) string {
77	return fmt.Sprintf("mcp__%s__%s", sanitizeName(serverName), sanitizeName(mcpToolName))
78}
79
80// loadServers reads the config and creates managed servers.
81func (b *bridge) loadServers(cfg Config) {
82	for name, srvCfg := range cfg.MCPServers {
83		srv := newManagedServer(name, srvCfg, b.cwd, b.logger)
84		b.servers[name] = srv
85	}
86}
87
88// registerCachedTools registers previously discovered tool definitions without
89// starting MCP servers. Tool calls still lazy-start the owning server on demand.
90func (b *bridge) registerCachedTools(cache toolCache) int {
91	count := 0
92	for serverName, srv := range b.servers {
93		cached, ok := cache.Servers[serverName]
94		if !ok || cached.Fingerprint != serverFingerprint(srv.config) {
95			continue
96		}
97		cachedNames := make([]mcp.Tool, 0, len(cached.Tools))
98		for _, tool := range cached.Tools {
99			b.registerCachedTool(serverName, tool)
100			cachedNames = append(cachedNames, mcp.Tool{Name: tool.Name})
101			count++
102		}
103		srv.mu.Lock()
104		if len(srv.tools) == 0 {
105			srv.tools = cachedNames
106		}
107		srv.mu.Unlock()
108	}
109	return count
110}
111
112// refreshToolCache discovers live tools and updates the on-disk cache.
113// It reports whether the cache changed; callers should only ask for /reload-ext
114// when changed is true.
115func (b *bridge) refreshToolCache(ctx context.Context, path string) (bool, error) {
116	previous, err := readToolCache(path)
117	if err != nil {
118		b.logger.Printf("read existing tool cache: %v", err)
119	}
120	cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
121	var wg sync.WaitGroup
122	errCh := make(chan error, len(b.servers))
123	var mu sync.Mutex
124
125	for name, srv := range b.servers {
126		wg.Add(1)
127		go func(n string, s *managedServer) {
128			defer wg.Done()
129			if err := s.start(ctx); err != nil {
130				b.logger.Printf("[%s] lazy discovery failed: %v", n, err)
131				errCh <- fmt.Errorf("%s: %w", n, err)
132				return
133			}
134
135			s.mu.Lock()
136			tools := append([]mcp.Tool(nil), s.tools...)
137			s.mu.Unlock()
138
139			cachedTools := make([]cachedTool, 0, len(tools))
140			for _, tool := range tools {
141				ct, err := cachedToolFromMCP(n, tool)
142				if err != nil {
143					b.logger.Printf("[%s] tool %s: schema marshal error: %v", n, tool.Name, err)
144					continue
145				}
146				cachedTools = append(cachedTools, ct)
147			}
148
149			sort.Slice(cachedTools, func(i, j int) bool { return cachedTools[i].Name < cachedTools[j].Name })
150
151			mu.Lock()
152			cache.Servers[n] = cachedServer{Fingerprint: serverFingerprint(s.config), Tools: cachedTools}
153			mu.Unlock()
154		}(name, srv)
155	}
156
157	wg.Wait()
158	close(errCh)
159
160	changed := !toolCachesEqual(previous, cache)
161	if changed {
162		if err := writeToolCache(path, cache); err != nil {
163			return false, err
164		}
165	}
166
167	var errs []string
168	for err := range errCh {
169		errs = append(errs, err.Error())
170	}
171	if len(errs) > 0 {
172		return changed, fmt.Errorf("%s", strings.Join(errs, "; "))
173	}
174	return changed, nil
175}
176
177func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
178	zotName := toolName(serverName, tool.Name)
179
180	b.mu.Lock()
181	if existing, exists := b.mapping[zotName]; exists {
182		b.mu.Unlock()
183		if existing.serverName != serverName || existing.mcpTool != tool.Name {
184			b.logger.Printf("tool name collision: %s already maps to %s/%s; skipping %s/%s",
185				zotName, existing.serverName, existing.mcpTool, serverName, tool.Name)
186		}
187		return
188	}
189	b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name}
190	b.mu.Unlock()
191
192	b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
193		return b.handleToolCall(zotName, args)
194	})
195
196	b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name)
197}
198
199func mcpToolDescription(serverName string, tool mcp.Tool) string {
200	desc := tool.Description
201	if tool.Annotations.Title != "" {
202		desc = tool.Annotations.Title + ": " + desc
203	}
204
205	var hints []string
206	if tool.Annotations.ReadOnlyHint != nil && *tool.Annotations.ReadOnlyHint {
207		hints = append(hints, "read-only")
208	}
209	if tool.Annotations.IdempotentHint != nil && *tool.Annotations.IdempotentHint {
210		hints = append(hints, "idempotent")
211	}
212	if tool.Annotations.OpenWorldHint != nil && !*tool.Annotations.OpenWorldHint {
213		hints = append(hints, "closed-world")
214	}
215	if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
216		hints = append(hints, "destructive")
217	}
218	if len(hints) > 0 {
219		desc += " [" + strings.Join(hints, ", ") + "]"
220	}
221	if desc == "" {
222		desc = fmt.Sprintf("MCP tool from server %q", serverName)
223	}
224	return desc
225}
226
227// mcpToolSchema converts an MCP Tool's input schema to a JSON Schema value.
228func mcpToolSchema(tool mcp.Tool) any {
229	if len(tool.RawInputSchema) > 0 {
230		return json.RawMessage(tool.RawInputSchema)
231	}
232
233	schema := map[string]any{
234		"type":       tool.InputSchema.Type,
235		"properties": tool.InputSchema.Properties,
236	}
237	if len(tool.InputSchema.Required) > 0 {
238		schema["required"] = tool.InputSchema.Required
239	}
240	if len(tool.InputSchema.Defs) > 0 {
241		schema["$defs"] = tool.InputSchema.Defs
242	}
243	if tool.InputSchema.AdditionalProperties != nil {
244		schema["additionalProperties"] = tool.InputSchema.AdditionalProperties
245	}
246	return schema
247}
248
249// handleToolCall routes a zot tool call to the appropriate MCP server.
250func (b *bridge) handleToolCall(zotName string, args json.RawMessage) ext.ToolResult {
251	b.mu.Lock()
252	mapping, ok := b.mapping[zotName]
253	b.mu.Unlock()
254
255	if !ok {
256		return ext.TextErrorResult(fmt.Sprintf(
257			"Tool '%s' not found. This tool was registered but is no longer available. "+
258				"The MCP server may have been stopped. Try running '/mcp' to check server status.",
259			zotName))
260	}
261
262	srv, ok := b.servers[mapping.serverName]
263	if !ok {
264		return ext.TextErrorResult(fmt.Sprintf(
265			"MCP server '%s' not found. The server configuration may have been removed. "+
266				"Check your mcp.json configuration file.",
267			mapping.serverName))
268	}
269
270	// managedServer owns all timeouts (connect + request); pass a
271	// plain context here.
272	result, err := srv.callTool(context.Background(), mapping.mcpTool, args)
273	if err != nil {
274		if errors.Is(err, context.DeadlineExceeded) {
275			return ext.TextErrorResult(timeoutErrorMessage(mapping.serverName, srv.config.RequestTimeout))
276		}
277		errMsg := err.Error()
278		if strings.Contains(errMsg, "connection") || strings.Contains(errMsg, "transport") {
279			return ext.TextErrorResult(fmt.Sprintf(
280				"Connection to MCP server '%s' failed: %v. The server may have crashed or been stopped. "+
281					"Try running '/mcp start %s' to restart that server, or '/mcp restart' to restart all servers.",
282				mapping.serverName, err, mapping.serverName))
283		}
284		return ext.TextErrorResult(fmt.Sprintf(
285			"MCP tool call failed: %v. Server: %s, Tool: %s. "+
286				"Check '/mcp %s' for server status.",
287			err, mapping.serverName, mapping.mcpTool, mapping.serverName))
288	}
289
290	// Convert MCP result to zot ToolResult
291	return mcpResultToZot(result)
292}
293
294// timeoutErrorMessage formats the user-facing text for a deadline error.
295func timeoutErrorMessage(serverName string, requestTimeout int) string {
296	return fmt.Sprintf(
297		"Tool call timed out after %d seconds. The MCP server '%s' may be slow or unresponsive. "+
298			"You can increase the timeout in your mcp.json config with 'requestTimeout'.",
299		requestTimeout, serverName)
300}
301
302// mcpResultToZot converts an MCP CallToolResult to a zot ToolResult.
303func mcpResultToZot(result *mcp.CallToolResult) ext.ToolResult {
304	if result == nil {
305		return ext.TextResult("(no result)")
306	}
307
308	var contents []ext.ToolContent
309	for _, c := range result.Content {
310		switch v := c.(type) {
311		case mcp.TextContent:
312			contents = append(contents, ext.Text(v.Text))
313		case mcp.ImageContent:
314			contents = append(contents, ext.Image(v.MIMEType, v.Data))
315		default:
316			// Fallback: try to marshal as JSON
317			data, _ := json.Marshal(v)
318			contents = append(contents, ext.Text(string(data)))
319		}
320	}
321
322	if len(contents) == 0 {
323		contents = append(contents, ext.Text("(empty result)"))
324	}
325
326	tr := ext.ToolResult{Content: contents}
327	if result.IsError {
328		tr.IsError = true
329	}
330	return tr
331}
332
333// startAll starts all configured servers without re-registering their tools with zot.
334func (b *bridge) startAll(ctx context.Context) error {
335	var wg sync.WaitGroup
336	errCh := make(chan error, len(b.servers))
337
338	for name, srv := range b.servers {
339		wg.Add(1)
340		go func(n string, s *managedServer) {
341			defer wg.Done()
342			if err := s.start(ctx); err != nil {
343				b.logger.Printf("[%s] failed to start: %v", n, err)
344				errCh <- fmt.Errorf("%s: %w", n, err)
345			}
346		}(name, srv)
347	}
348
349	wg.Wait()
350	close(errCh)
351
352	var errs []string
353	for err := range errCh {
354		errs = append(errs, err.Error())
355	}
356	if len(errs) > 0 {
357		return fmt.Errorf("%s", strings.Join(errs, "; "))
358	}
359	return nil
360}
361
362// startIdleReaper runs a background goroutine that kills idle servers.
363// It has no shutdown mechanism by design: it lives exactly as long as
364// the extension process, and zot reaps the whole process on exit.
365func (b *bridge) startIdleReaper() {
366	go func() {
367		ticker := time.NewTicker(30 * time.Second)
368		defer ticker.Stop()
369		for range ticker.C {
370			for _, srv := range b.servers {
371				// Use per-server idle timeout
372				idleTimeout := time.Duration(srv.config.IdleTimeout) * time.Second
373				if srv.isIdle(idleTimeout) {
374					b.logger.Printf("[%s] idle timeout, stopping", srv.name)
375					srv.stop()
376				}
377			}
378		}
379	}()
380}
381
382// stopAll shuts down all MCP servers.
383func (b *bridge) stopAll() {
384	for _, srv := range b.servers {
385		srv.stop()
386	}
387}
388
389// serverStatus returns compact status info for all servers in stable order.
390func (b *bridge) serverStatus() []string {
391	names := make([]string, 0, len(b.servers))
392	for name := range b.servers {
393		names = append(names, name)
394	}
395	sort.Strings(names)
396
397	lines := make([]string, 0, len(names))
398	for _, name := range names {
399		lines = append(lines, b.servers[name].status())
400	}
401	return lines
402}
403
404func (b *bridge) notifyLevel() string {
405	if len(b.servers) == 0 {
406		return "info"
407	}
408	ready := 0
409	errored := 0
410	for _, srv := range b.servers {
411		srv.mu.Lock()
412		state := srv.state
413		srv.mu.Unlock()
414		switch state {
415		case stateReady:
416			ready++
417		case stateError:
418			errored++
419		}
420	}
421	if errored > 0 && ready == 0 {
422		return "error"
423	}
424	if errored > 0 {
425		return "warn"
426	}
427	return "success"
428}
429
430// startServer manually starts a specific server.
431func (b *bridge) startServer(name string) error {
432	srv, ok := b.servers[name]
433	if !ok {
434		return fmt.Errorf("unknown server: %s", name)
435	}
436	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
437	defer cancel()
438	return srv.start(ctx)
439}
440
441// stopServer manually stops a specific server.
442func (b *bridge) stopServer(name string) error {
443	srv, ok := b.servers[name]
444	if !ok {
445		return fmt.Errorf("unknown server: %s", name)
446	}
447	srv.stop()
448	return nil
449}

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