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}