v0.2.73 dbd0dfa

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.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, cwd, 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}

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