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.0.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, logger)
80 b.loadServers(cfg)
81
82 cachePath := toolCachePath()
83 cache, err := readToolCache(cachePath)
84 if err != nil {
85 logger.Printf("tool cache error: %v", err)
86 }
87 cachedToolCount := b.registerCachedTools(cache)
88 logger.Printf("registered %d cached MCP tool(s)", cachedToolCount)
89
90 b.startIdleReaper()
91 registerCommands(e, b)
92 startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount)
93 })
94
95 // Run the extension protocol loop
96 if err := e.Run(); err != nil {
97 logger.Printf("fatal: %v", err)
98 }
99
100 // Cleanup
101 if b != nil {
102 b.stopAll()
103 }
104}
105
106func startBackgroundToolRefresh(e *ext.Extension, b *bridge, logger *log.Logger, cachePath string, cachedToolCount int) {
107 // Refresh discovery in the background so zot startup is not blocked. Only ask
108 // for /reload-ext if the discovered tool cache actually changed; otherwise a
109 // reload would just repeat this cycle without adding anything.
110 go func() {
111 time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first.
112 if cachedToolCount == 0 {
113 notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.")
114 } else {
115 notifyText(e, "success", fmt.Sprintf("MCP loaded %d cached tool(s). Refreshing cache in background.", cachedToolCount))
116 }
117
118 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
119 defer cancel()
120 changed, err := b.refreshToolCache(ctx, cachePath)
121 if err != nil {
122 logger.Printf("tool cache refresh error: %v", err)
123 notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error())
124 return
125 }
126 if changed {
127 notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.")
128 return
129 }
130 notifyBridgeStatus(e, b)
131 }()
132}
133
134// registerCommands sets up the /mcp slash commands.
135func registerCommands(e *ext.Extension, b *bridge) {
136 e.Command("mcp", "show MCP server status or manage servers", func(args string) ext.Response {
137 args = strings.TrimSpace(args)
138
139 // Parse subcommand
140 parts := strings.Fields(args)
141 if len(parts) == 0 {
142 // /mcp — show a compact dashboard: status plus discoverable actions.
143 notifyText(e, "info", mcpOverview(b))
144 return ext.Noop()
145 }
146
147 switch parts[0] {
148 case "help", "--help", "-h":
149 notifyText(e, "info", mcpHelp(b))
150 return ext.Noop()
151
152 case "setup":
153 out, err := handleSetup(parts[1:], e.Host().CWD)
154 if err != nil {
155 return ext.Errorf("%v", err)
156 }
157 notifyText(e, "info", out)
158 return ext.Noop()
159
160 case "start":
161 return handleStartCommand(e, b, parts[1:])
162
163 case "stop":
164 return handleStopCommand(e, b, parts[1:])
165
166 case "restart":
167 return handleRestartCommand(e, b)
168
169 case "refresh", "discover":
170 return handleRefreshCommand(e, b, toolCachePath())
171
172 default:
173 // /mcp <name> — show detailed status for one server
174 if b == nil {
175 return ext.Errorf("no servers configured")
176 }
177 name := parts[0]
178 srv, ok := b.servers[name]
179 if !ok {
180 return ext.Errorf("unknown server: %s", name)
181 }
182 notifyText(e, serverNotifyLevel(srv), srv.detailStatus())
183 return ext.Noop()
184 }
185 })
186
187}
188
189func mcpOverview(b *bridge) string {
190 var sb strings.Builder
191 sb.WriteString("MCP status\n")
192 if b == nil || len(b.servers) == 0 {
193 sb.WriteString(" no servers configured\n")
194 } else {
195 for _, line := range b.serverStatus() {
196 sb.WriteString(" ")
197 sb.WriteString(line)
198 sb.WriteByte('\n')
199 }
200 }
201 sb.WriteString("\nCommon commands\n")
202 sb.WriteString(" /mcp <server> Show detailed status for one server\n")
203 sb.WriteString(" /mcp start <server|all> Start one server, or all servers\n")
204 sb.WriteString(" /mcp stop <server|all> Stop one server, or all servers\n")
205 sb.WriteString(" /mcp restart Restart all servers\n")
206 sb.WriteString(" /mcp refresh Refresh cached tool definitions\n")
207 sb.WriteString(" /mcp help Show all MCP commands\n")
208 return strings.TrimRight(sb.String(), "\n")
209}
210
211func mcpHelp(b *bridge) string {
212 var sb strings.Builder
213 sb.WriteString(mcpOverview(b))
214 sb.WriteString("\n\nSetup commands\n")
215 sb.WriteString(" /mcp setup templates List setup templates\n")
216 sb.WriteString(" /mcp setup add <template> [options] Add a server template\n")
217 sb.WriteString("\nSetup options\n")
218 sb.WriteString(" --global Write to $ZOT_HOME/mcp.json (default)\n")
219 sb.WriteString(" --project Write to .zot/mcp.json\n")
220 sb.WriteString(" --name <server-name> Use a custom configured server name\n")
221 return strings.TrimRight(sb.String(), "\n")
222}
223
224func handleStartCommand(e *ext.Extension, b *bridge, args []string) ext.Response {
225 if len(args) != 1 {
226 return ext.Errorf("usage: /mcp start <server-name|all>")
227 }
228 if b == nil {
229 return ext.Errorf("no servers configured")
230 }
231 name := args[0]
232 if name == "all" {
233 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
234 defer cancel()
235 if err := b.startAll(ctx); err != nil {
236 return ext.Errorf("start all failed: %v", err)
237 }
238 notifyBridgeStatus(e, b)
239 return ext.Noop()
240 }
241 if err := b.startServer(name); err != nil {
242 return ext.Errorf("start %s: %v", name, err)
243 }
244 notifyBridgeStatus(e, b)
245 return ext.Noop()
246}
247
248func handleStopCommand(e *ext.Extension, b *bridge, args []string) ext.Response {
249 if len(args) != 1 {
250 return ext.Errorf("usage: /mcp stop <server-name|all>")
251 }
252 if b == nil {
253 return ext.Errorf("no servers configured")
254 }
255 name := args[0]
256 if name == "all" {
257 b.stopAll()
258 notifyBridgeStatus(e, b)
259 return ext.Noop()
260 }
261 if err := b.stopServer(name); err != nil {
262 return ext.Errorf("stop %s: %v", name, err)
263 }
264 notifyBridgeStatus(e, b)
265 return ext.Noop()
266}
267
268func handleRestartCommand(e *ext.Extension, b *bridge) ext.Response {
269 if b == nil {
270 return ext.Errorf("no servers configured")
271 }
272 b.stopAll()
273 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
274 defer cancel()
275 if err := b.startAll(ctx); err != nil {
276 return ext.Errorf("restart failed: %v", err)
277 }
278 notifyBridgeStatus(e, b)
279 return ext.Noop()
280}
281
282func handleRefreshCommand(e *ext.Extension, b *bridge, cachePath string) ext.Response {
283 if b == nil {
284 return ext.Errorf("no servers configured")
285 }
286 notifyText(e, "info", "Refreshing MCP tool cache…")
287 go func() {
288 ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
289 defer cancel()
290 changed, err := b.refreshToolCache(ctx, cachePath)
291 if err != nil {
292 notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error())
293 return
294 }
295 if changed {
296 notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.")
297 return
298 }
299 notifyBridgeStatus(e, b)
300 }()
301 return ext.Noop()
302}
303
304func notifyBridgeStatus(e *ext.Extension, b *bridge) {
305 if e == nil || b == nil {
306 return
307 }
308 notifyText(e, b.notifyLevel(), formatStatusSummary(b))
309}
310
311func serverNotifyLevel(srv *managedServer) string {
312 if srv == nil {
313 return "info"
314 }
315 srv.mu.Lock()
316 state := srv.state
317 srv.mu.Unlock()
318 switch state {
319 case stateReady:
320 return "success"
321 case stateError:
322 return "error"
323 default:
324 return "info"
325 }
326}
327
328func notifyText(e *ext.Extension, level, text string) {
329 if e == nil {
330 return
331 }
332 clearExtensionNotes()
333 e.Notify(level, text)
334}
335
336func clearExtensionNotes() {
337 _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n")
338}
339
340// formatStatusSummary builds a human-readable status line.
341func formatStatusSummary(b *bridge) string {
342 lines := b.serverStatus()
343 if len(lines) == 0 {
344 return "no MCP servers"
345 }
346 return strings.Join(lines, " | ")
347}