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}