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, lsp, 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/bnema/zut/packages/agent/ext"
24 "github.com/mark3labs/mcp-go/mcp"
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 zut 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 zut'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 // zut 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→zut 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 zut tool name component.
86// Zut 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 zut 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 zutName := toolName(serverName, tool.Name)
226
227 b.mu.Lock()
228 if existing, exists := b.mapping[zutName]; 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 zutName, existing.serverName, existing.mcpTool, serverName, tool.Name)
233 }
234 return
235 }
236 b.mapping[zutName] = toolMapping{
237 serverName: serverName,
238 mcpTool: tool.Name,
239 description: tool.Description,
240 }
241 b.mu.Unlock()
242
243 b.e.DeferredTool(zutName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
244 return b.handleToolCall(zutName, args)
245 })
246
247 b.logger.Printf("registered deferred cached tool: %s → %s/%s", zutName, 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, zutName string, mapping toolMapping) int {
330 name := strings.ToLower(zutName)
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 zut tool call to the appropriate MCP server.
412func (b *bridge) handleToolCall(zutName string, args json.RawMessage) ext.ToolResult {
413 b.mu.Lock()
414 mapping, ok := b.mapping[zutName]
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 zutName))
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 zut ToolResult
453 return mcpResultToZut(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// mcpResultToZut converts an MCP CallToolResult to a zut ToolResult.
465func mcpResultToZut(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 zut.
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 zut 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}