1// mcp-bridge — Connect zot to MCP (Model Context Protocol) servers.
2//
3// This extension reads MCP server configurations from standard locations
4// (same format as Claude Desktop, Cursor, etc.) and bridges their tools
5// into zot so the LLM can call them.
6//
7// Config locations:
8// - Global: $ZOT_HOME/mcp.json
9// - Project: .zot/mcp.json
10//
11// Smart lazy: servers are spawned on startup to discover tools, then
12// killed after 5 minutes of idle time. On the next tool call, they're
13// respawned automatically.
14//
15// Tool naming: mcp__<server>__<tool>
16//
17// Slash commands:
18//
19// /mcp — show status of all configured servers
20// /mcp start <name> — manually start a server
21// /mcp stop <name> — manually stop a server
22// /mcp restart — restart all servers
23// /mcp start all — manually start all servers
24// /mcp stop all — manually stop all servers
25//
26// Build:
27//
28// cd examples/extensions/mcp-bridge
29// go build -o mcp-bridge .
30//
31// Install:
32//
33// zot ext install .
34package main
35
36import (
37 "context"
38 "fmt"
39 "log"
40 "os"
41 "strings"
42 "time"
43
44 "github.com/patriceckhart/zot/packages/agent/ext"
45)
46
47func main() {
48 e := ext.New("mcp", "1.1.0")
49
50 // Logger writes to stderr (captured by zot into ext logs)
51 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)
52
53 var b *bridge
54 e.OnHello(func(host ext.HostInfo) {
55 cwd := host.CWD
56 if cwd == "" {
57 var err error
58 cwd, err = os.Getwd()
59 if err != nil {
60 logger.Printf("getwd fallback: %v", err)
61 }
62 }
63
64 cfg, err := loadConfig(cwd)
65 if err != nil {
66 logger.Printf("config error: %v", err)
67 notifyText(e, "error", "mcp: config error: "+err.Error())
68 }
69
70 if len(cfg.MCPServers) == 0 {
71 logger.Printf("no MCP servers configured")
72 // Still register commands so user can check status and run setup.
73 registerCommands(e, nil)
74 return
75 }
76
77 logger.Printf("found %d MCP server(s)", len(cfg.MCPServers))
78
79 b = newBridge(e, cwd, logger)
80 b.loadServers(cfg)
81 b.registerToolSearch()
82
83 cachePath := toolCachePath()
84 cache, err := readToolCache(cachePath)
85 if err != nil {
86 logger.Printf("tool cache error: %v", err)
87 }
88 cachedToolCount := b.registerCachedTools(cache)
89 logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
90
91 b.startIdleReaper()
92 registerCommands(e, b)
93 startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount)
94 })
95
96 // Run the extension protocol loop
97 if err := e.Run(); err != nil {
98 logger.Printf("fatal: %v", err)
99 }
100
101 // Cleanup
102 if b != nil {
103 b.stopAll()
104 }
105}
106
107func startBackgroundToolRefresh(e *ext.Extension, b *bridge, logger *log.Logger, cachePath string, cachedToolCount int) {
108 // Refresh discovery in the background so zot startup is not blocked. Only ask
109 // for /reload-ext if the discovered tool cache actually changed; otherwise a
110 // reload would just repeat this cycle without adding anything.
111 go func() {
112 time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first.
113 if cachedToolCount == 0 {
114 notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.")
115 } else {
116 notifyText(e, "success", fmt.Sprintf("MCP loaded %d cached tool(s). Refreshing cache in background.", cachedToolCount))
117 }
118
119 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
120 defer cancel()
121 changed, err := b.refreshToolCache(ctx, cachePath)
122 if err != nil {
123 logger.Printf("tool cache refresh error: %v", err)
124 notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error())
125 return
126 }
127 if changed {
128 notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.")
129 return
130 }
131 notifyBridgeStatus(e, b)
132 }()
133}
134
135// registerCommands sets up the /mcp slash commands.
136func registerCommands(e *ext.Extension, b *bridge) {
137 e.Command("mcp", "show MCP server status or manage servers", func(args string) ext.Response {
138 args = strings.TrimSpace(args)
139
140 // Parse subcommand
141 parts := strings.Fields(args)
142 if len(parts) == 0 {
143 // /mcp — show a compact dashboard: status plus discoverable actions.
144 notifyText(e, "info", mcpOverview(b))
145 return ext.Noop()
146 }
147
148 switch parts[0] {
149 case "help", "--help", "-h":
150 notifyText(e, "info", mcpHelp(b))
151 return ext.Noop()
152
153 case "setup":
154 out, err := handleSetup(parts[1:], e.Host().CWD)
155 if err != nil {
156 return ext.Errorf("%v", err)
157 }
158 notifyText(e, "info", out)
159 return ext.Noop()
160
161 case "start":
162 return handleStartCommand(e, b, parts[1:])
163
164 case "stop":
165 return handleStopCommand(e, b, parts[1:])
166
167 case "restart":
168 return handleRestartCommand(e, b)
169
170 case "refresh", "discover":
171 return handleRefreshCommand(e, b, toolCachePath())
172
173 default:
174 // /mcp <name> — show detailed status for one server
175 if b == nil {
176 return ext.Errorf("no servers configured")
177 }
178 name := parts[0]
179 srv, ok := b.servers[name]
180 if !ok {
181 return ext.Errorf("unknown server: %s", name)
182 }
183 notifyText(e, serverNotifyLevel(srv), srv.detailStatus())
184 return ext.Noop()
185 }
186 })
187
188}
189
190func mcpOverview(b *bridge) string {
191 var sb strings.Builder
192 sb.WriteString("MCP status\n")
193 if b == nil || len(b.servers) == 0 {
194 sb.WriteString(" no servers configured\n")
195 } else {
196 for _, line := range b.serverStatus() {
197 sb.WriteString(" ")
198 sb.WriteString(line)
199 sb.WriteByte('\n')
200 }
201 }
202 sb.WriteString("\nCommon commands\n")
203 sb.WriteString(" /mcp <server> Show detailed status for one server\n")
204 sb.WriteString(" /mcp start <server|all> Start one server, or all servers\n")
205 sb.WriteString(" /mcp stop <server|all> Stop one server, or all servers\n")
206 sb.WriteString(" /mcp restart Restart all servers\n")
207 sb.WriteString(" /mcp refresh Refresh cached tool definitions\n")
208 sb.WriteString(" /mcp help Show all MCP commands\n")
209 return strings.TrimRight(sb.String(), "\n")
210}
211
212func mcpHelp(b *bridge) string {
213 var sb strings.Builder
214 sb.WriteString(mcpOverview(b))
215 sb.WriteString("\n\nSetup commands\n")
216 sb.WriteString(" /mcp setup templates List setup templates\n")
217 sb.WriteString(" /mcp setup add <template> [options] Add a server template\n")
218 sb.WriteString("\nSetup options\n")
219 sb.WriteString(" --global Write to $ZOT_HOME/mcp.json (default)\n")
220 sb.WriteString(" --project Write to .zot/mcp.json\n")
221 sb.WriteString(" --name <server-name> Use a custom configured server name\n")
222 return strings.TrimRight(sb.String(), "\n")
223}
224
225func handleStartCommand(e *ext.Extension, b *bridge, args []string) ext.Response {
226 if len(args) != 1 {
227 return ext.Errorf("usage: /mcp start <server-name|all>")
228 }
229 if b == nil {
230 return ext.Errorf("no servers configured")
231 }
232 name := args[0]
233 if name == "all" {
234 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
235 defer cancel()
236 if err := b.startAll(ctx); err != nil {
237 return ext.Errorf("start all failed: %v", err)
238 }
239 notifyBridgeStatus(e, b)
240 return ext.Noop()
241 }
242 if err := b.startServer(name); err != nil {
243 return ext.Errorf("start %s: %v", name, err)
244 }
245 notifyBridgeStatus(e, b)
246 return ext.Noop()
247}
248
249func handleStopCommand(e *ext.Extension, b *bridge, args []string) ext.Response {
250 if len(args) != 1 {
251 return ext.Errorf("usage: /mcp stop <server-name|all>")
252 }
253 if b == nil {
254 return ext.Errorf("no servers configured")
255 }
256 name := args[0]
257 if name == "all" {
258 b.stopAll()
259 notifyBridgeStatus(e, b)
260 return ext.Noop()
261 }
262 if err := b.stopServer(name); err != nil {
263 return ext.Errorf("stop %s: %v", name, err)
264 }
265 notifyBridgeStatus(e, b)
266 return ext.Noop()
267}
268
269func handleRestartCommand(e *ext.Extension, b *bridge) ext.Response {
270 if b == nil {
271 return ext.Errorf("no servers configured")
272 }
273 b.stopAll()
274 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
275 defer cancel()
276 if err := b.startAll(ctx); err != nil {
277 return ext.Errorf("restart failed: %v", err)
278 }
279 notifyBridgeStatus(e, b)
280 return ext.Noop()
281}
282
283func handleRefreshCommand(e *ext.Extension, b *bridge, cachePath string) ext.Response {
284 if b == nil {
285 return ext.Errorf("no servers configured")
286 }
287 notifyText(e, "info", "Refreshing MCP tool cache…")
288 go func() {
289 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
290 defer cancel()
291 changed, err := b.refreshToolCache(ctx, cachePath)
292 if err != nil {
293 notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error())
294 return
295 }
296 if changed {
297 notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.")
298 return
299 }
300 notifyBridgeStatus(e, b)
301 }()
302 return ext.Noop()
303}
304
305func notifyBridgeStatus(e *ext.Extension, b *bridge) {
306 if e == nil || b == nil {
307 return
308 }
309 notifyText(e, b.notifyLevel(), formatStatusSummary(b))
310}
311
312func serverNotifyLevel(srv *managedServer) string {
313 if srv == nil {
314 return "info"
315 }
316 srv.mu.Lock()
317 state := srv.state
318 srv.mu.Unlock()
319 switch state {
320 case stateReady:
321 return "success"
322 case stateError:
323 return "error"
324 default:
325 return "info"
326 }
327}
328
329func notifyText(e *ext.Extension, level, text string) {
330 if e == nil {
331 return
332 }
333 clearExtensionNotes()
334 e.Notify(level, text)
335}
336
337func clearExtensionNotes() {
338 _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n")
339}
340
341// formatStatusSummary builds a human-readable status line.
342func formatStatusSummary(b *bridge) string {
343 lines := b.serverStatus()
344 if len(lines) == 0 {
345 return "no MCP servers"
346 }
347 return strings.Join(lines, " | ")
348}