v0.2.97 d25ea2b

main.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close