detached be0642f

Merge branch 'main' into feat/extension-doctor

commit
be0642fbdb0b1c485dd31cf4bff9481898954232
author
Patric Eckhart · · 2026-07-03 17:49 UTC
committer
GitHub · · 2026-07-03 17:49 UTC
merge of
167479a + 7368d56 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.57
22 files · +2858 -54
M docs/extensions.md +45 -21
@@ -25,20 +25,22 @@ no SDK required:
2525 ```python
2626 #!/usr/bin/env python3
2727 # $ZOT_HOME/extensions/hello-py/hello.py
28-import json, sys, threading
28+import json, sys
2929
3030 def emit(obj):
3131 sys.stdout.write(json.dumps(obj) + "\n")
3232 sys.stdout.flush()
3333
3434 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
35-emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
3635
3736 for line in sys.stdin:
3837 msg = json.loads(line)
39- if msg["type"] == "command_invoked":
38+ if msg["type"] == "hello_ack":
39+ emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
40+ emit({"type":"ready"})
41+ elif msg["type"] == "command_invoked":
4042 emit({"type":"command_response","id":msg["id"],"action":"prompt",
41- "prompt": "Greet me very briefly. Add one emoji."})
43+ "prompt": "Greet me very briefly in one sentence."})
4244 elif msg["type"] == "shutdown":
4345 emit({"type":"shutdown_ack"})
4446 break
@@ -135,19 +137,21 @@ manifest tells zot how to launch it:
135137 2. **Spawn**: enabled extensions are launched as subprocesses. stderr
136138 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
137139 extension, append-mode).
138-3. **Hello handshake**: the extension sends a `hello` frame; zot
139- replies with `hello_ack` containing the protocol version, the
140- active provider/model/cwd, and the extension's own data directory
141- so it can persist files beside its manifest.
142-4. **Registration**: the extension sends `register_command` frames.
143- First-come-first-served: a name already taken by a built-in or by
144- a previously-loaded extension is silently shadowed (logged in the
145- extension's own log file).
146-5. **Runtime**: zot dispatches `command_invoked` frames when the
147- user runs a registered command; the extension responds with
148- `command_response`. Extensions can also push `notify` frames at
149- any time. Panel-capable extensions may open an interactive panel,
150- receive key events, and push redraws while the panel is focused.
140+3. **Hello handshake**: the extension's first stdout frame must be
141+ `hello`; zot replies with `hello_ack` containing the protocol
142+ version, the active provider/model/cwd, and the extension's own
143+ data directory so it can persist files beside its manifest.
144+4. **Registration**: after receiving `hello_ack`, the extension sends
145+ `register_command`, `register_tool`, and subscription frames, then
146+ sends `ready`. First-come-first-served: a name already taken by a
147+ built-in or by a previously-loaded extension is silently shadowed
148+ (logged in the extension's own log file).
149+5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
150+ when the user runs a registered command; the extension responds
151+ with `command_response`. Extensions can also push `notify` frames
152+ during runtime. Panel-capable extensions may open an interactive
153+ panel, receive key events, and push redraws while the panel is
154+ focused.
151155 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
152156 2s for the extension to send `shutdown_ack`. Holdouts are
153157 SIGTERM'd, then SIGKILL'd.
@@ -160,7 +164,9 @@ restarted.
160164
161165 All frames are one JSON object per line. Top-level `type` is the
162166 discriminator. Optional `id` correlates request frames with their
163-responses.
167+responses. The canonical startup order is `hello`, `hello_ack`,
168+registration frames, then `ready`. Do not send `notify`, logs, or any
169+other stdout frame before `hello`.
164170
165171 ### Extension → host
166172
@@ -370,9 +376,13 @@ Sent in response to `shutdown`. Extension should exit promptly after.
370376 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
371377 ```
372378
373-Sent immediately after `hello`. The extension can use these fields to
374-decide which commands to register (e.g. only register a Python tool
375-on macOS, only register a model-specific shortcut for opus, etc.).
379+Sent immediately after `hello`. Wait for this frame before sending
380+registrations if they depend on host metadata. The extension can use
381+these fields to decide which commands to register (e.g. only register
382+a Python tool on macOS, only register a model-specific shortcut for
383+opus, etc.). `cwd` is the user's project directory; the extension
384+process itself runs from the extension directory, so do not use
385+`os.Getwd()` or `process.cwd()` when you need the project path.
376386 `extension_dir` / `data_dir` are where the extension should persist
377387 its own state (for example `todos.json`, cached metadata, or auth
378388 tokens scoped to that extension).
@@ -533,6 +543,15 @@ func main() {
533543 return ext.TextResult(in.City + ": sunny")
534544 })
535545
546+ // Optional: register project-specific commands after hello_ack.
547+ e.OnHello(func(host ext.HostInfo) {
548+ if host.CWD != "" {
549+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
550+ return ext.Display(host.CWD)
551+ })
552+ }
553+ })
554+
536555 e.Run()
537556 }
538557 ```
@@ -540,6 +559,11 @@ func main() {
540559 Build with `go build -o hello .`, drop the binary + an `extension.json`
541560 into `$ZOT_HOME/extensions/hello/`.
542561
562+`OnHello` is optional. Use it when configuration or registrations need
563+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
564+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
565+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
566+
543567 The SDK has four interceptor hooks, all optional:
544568
545569 ```go
@@ -25,20 +25,22 @@ no SDK required:
25 ```python25 ```python
26 #!/usr/bin/env python326 #!/usr/bin/env python3
27 # $ZOT_HOME/extensions/hello-py/hello.py27 # $ZOT_HOME/extensions/hello-py/hello.py
28-import json, sys, threading28+import json, sys
29 29
30 def emit(obj):30 def emit(obj):
31 sys.stdout.write(json.dumps(obj) + "\n")31 sys.stdout.write(json.dumps(obj) + "\n")
32 sys.stdout.flush()32 sys.stdout.flush()
33 33
34 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})34 emit({"type":"hello","name":"hello-py","version":"1.0.0","capabilities":["commands"]})
35-emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
36 35
37 for line in sys.stdin:36 for line in sys.stdin:
38 msg = json.loads(line)37 msg = json.loads(line)
39- if msg["type"] == "command_invoked":38+ if msg["type"] == "hello_ack":
39+ emit({"type":"register_command","name":"hellopy","description":"say hi (python)"})
40+ emit({"type":"ready"})
41+ elif msg["type"] == "command_invoked":
40 emit({"type":"command_response","id":msg["id"],"action":"prompt",42 emit({"type":"command_response","id":msg["id"],"action":"prompt",
41- "prompt": "Greet me very briefly. Add one emoji."})43+ "prompt": "Greet me very briefly in one sentence."})
42 elif msg["type"] == "shutdown":44 elif msg["type"] == "shutdown":
43 emit({"type":"shutdown_ack"})45 emit({"type":"shutdown_ack"})
44 break46 break
@@ -135,19 +137,21 @@ manifest tells zot how to launch it:
135 2. **Spawn**: enabled extensions are launched as subprocesses. stderr137 2. **Spawn**: enabled extensions are launched as subprocesses. stderr
136 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per138 redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per
137 extension, append-mode).139 extension, append-mode).
138-3. **Hello handshake**: the extension sends a `hello` frame; zot140+3. **Hello handshake**: the extension's first stdout frame must be
139- replies with `hello_ack` containing the protocol version, the141+ `hello`; zot replies with `hello_ack` containing the protocol
140- active provider/model/cwd, and the extension's own data directory142+ version, the active provider/model/cwd, and the extension's own
141- so it can persist files beside its manifest.143+ data directory so it can persist files beside its manifest.
142-4. **Registration**: the extension sends `register_command` frames.144+4. **Registration**: after receiving `hello_ack`, the extension sends
143- First-come-first-served: a name already taken by a built-in or by145+ `register_command`, `register_tool`, and subscription frames, then
144- a previously-loaded extension is silently shadowed (logged in the146+ sends `ready`. First-come-first-served: a name already taken by a
145- extension's own log file).147+ built-in or by a previously-loaded extension is silently shadowed
146-5. **Runtime**: zot dispatches `command_invoked` frames when the148+ (logged in the extension's own log file).
147- user runs a registered command; the extension responds with149+5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames
148- `command_response`. Extensions can also push `notify` frames at150+ when the user runs a registered command; the extension responds
149- any time. Panel-capable extensions may open an interactive panel,151+ with `command_response`. Extensions can also push `notify` frames
150- receive key events, and push redraws while the panel is focused.152+ during runtime. Panel-capable extensions may open an interactive
153+ panel, receive key events, and push redraws while the panel is
154+ focused.
151 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to155 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to
152 2s for the extension to send `shutdown_ack`. Holdouts are156 2s for the extension to send `shutdown_ack`. Holdouts are
153 SIGTERM'd, then SIGKILL'd.157 SIGTERM'd, then SIGKILL'd.
@@ -160,7 +164,9 @@ restarted.
160 164
161 All frames are one JSON object per line. Top-level `type` is the165 All frames are one JSON object per line. Top-level `type` is the
162 discriminator. Optional `id` correlates request frames with their166 discriminator. Optional `id` correlates request frames with their
163-responses.167+responses. The canonical startup order is `hello`, `hello_ack`,
168+registration frames, then `ready`. Do not send `notify`, logs, or any
169+other stdout frame before `hello`.
164 170
165 ### Extension → host171 ### Extension → host
166 172
@@ -370,9 +376,13 @@ Sent in response to `shutdown`. Extension should exit promptly after.
370 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}376 "data_dir":"/Users/pat/Developer/zot/.zot/extensions/todos"}
371 ```377 ```
372 378
373-Sent immediately after `hello`. The extension can use these fields to379+Sent immediately after `hello`. Wait for this frame before sending
374-decide which commands to register (e.g. only register a Python tool380+registrations if they depend on host metadata. The extension can use
375-on macOS, only register a model-specific shortcut for opus, etc.).381+these fields to decide which commands to register (e.g. only register
382+a Python tool on macOS, only register a model-specific shortcut for
383+opus, etc.). `cwd` is the user's project directory; the extension
384+process itself runs from the extension directory, so do not use
385+`os.Getwd()` or `process.cwd()` when you need the project path.
376 `extension_dir` / `data_dir` are where the extension should persist386 `extension_dir` / `data_dir` are where the extension should persist
377 its own state (for example `todos.json`, cached metadata, or auth387 its own state (for example `todos.json`, cached metadata, or auth
378 tokens scoped to that extension).388 tokens scoped to that extension).
@@ -533,6 +543,15 @@ func main() {
533 return ext.TextResult(in.City + ": sunny")543 return ext.TextResult(in.City + ": sunny")
534 })544 })
535 545
546+ // Optional: register project-specific commands after hello_ack.
547+ e.OnHello(func(host ext.HostInfo) {
548+ if host.CWD != "" {
549+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
550+ return ext.Display(host.CWD)
551+ })
552+ }
553+ })
554+
536 e.Run()555 e.Run()
537 }556 }
538 ```557 ```
@@ -540,6 +559,11 @@ func main() {
540 Build with `go build -o hello .`, drop the binary + an `extension.json`559 Build with `go build -o hello .`, drop the binary + an `extension.json`
541 into `$ZOT_HOME/extensions/hello/`.560 into `$ZOT_HOME/extensions/hello/`.
542 561
562+`OnHello` is optional. Use it when configuration or registrations need
563+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
564+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
565+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
566+
543 The SDK has four interceptor hooks, all optional:567 The SDK has four interceptor hooks, all optional:
544 568
545 ```go569 ```go
A examples/extensions/mcp-bridge/.gitignore +2 -0
@@ -0,0 +1,2 @@
1+.install
2+mcp-bridge
@@ -0,0 +1,2 @@
1+.install
2+mcp-bridge
A examples/extensions/mcp-bridge/Makefile +28 -0
@@ -0,0 +1,28 @@
1+.PHONY: default build install uninstall
2+
3+BIN := $(notdir $(CURDIR))
4+# Must match "name" in extension.json (slash command/logical extension name).
5+EXT := mcp
6+ZOT_HOME ?= $(shell \
7+ if [ "$$(uname -s)" = "Darwin" ]; then \
8+ printf '%s/Library/Application Support/zot' "$$HOME"; \
9+ elif [ -n "$$XDG_STATE_HOME" ]; then \
10+ printf '%s/zot' "$$XDG_STATE_HOME"; \
11+ else \
12+ printf '%s/.local/state/zot' "$$HOME"; \
13+ fi)
14+
15+default: install
16+
17+build:
18+ go build -o $(BIN) .
19+
20+install:
21+ -zot ext remove $(EXT) --yes
22+ rm -rf "$(ZOT_HOME)/extensions/$(BIN)"
23+ mkdir -p "$(ZOT_HOME)/extensions/$(EXT)"
24+ cp extension.json "$(ZOT_HOME)/extensions/$(EXT)/extension.json"
25+ go build -o "$(ZOT_HOME)/extensions/$(EXT)/$(BIN)" .
26+
27+uninstall:
28+ -zot ext remove $(EXT) --yes
@@ -0,0 +1,28 @@
1+.PHONY: default build install uninstall
2+
3+BIN := $(notdir $(CURDIR))
4+# Must match "name" in extension.json (slash command/logical extension name).
5+EXT := mcp
6+ZOT_HOME ?= $(shell \
7+ if [ "$$(uname -s)" = "Darwin" ]; then \
8+ printf '%s/Library/Application Support/zot' "$$HOME"; \
9+ elif [ -n "$$XDG_STATE_HOME" ]; then \
10+ printf '%s/zot' "$$XDG_STATE_HOME"; \
11+ else \
12+ printf '%s/.local/state/zot' "$$HOME"; \
13+ fi)
14+
15+default: install
16+
17+build:
18+ go build -o $(BIN) .
19+
20+install:
21+ -zot ext remove $(EXT) --yes
22+ rm -rf "$(ZOT_HOME)/extensions/$(BIN)"
23+ mkdir -p "$(ZOT_HOME)/extensions/$(EXT)"
24+ cp extension.json "$(ZOT_HOME)/extensions/$(EXT)/extension.json"
25+ go build -o "$(ZOT_HOME)/extensions/$(EXT)/$(BIN)" .
26+
27+uninstall:
28+ -zot ext remove $(EXT) --yes
A examples/extensions/mcp-bridge/README.md +288 -0
@@ -0,0 +1,288 @@
1+# mcp-bridge
2+
3+Connect zot to [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers.
4+
5+This extension reads MCP server configurations from standard locations (same format as Claude Desktop, Cursor, Cline, etc.) and bridges their tools into zot so the LLM can call them directly.
6+
7+## Features
8+
9+- **Standard config format** — same JSON as Claude Desktop, Cursor, Cline
10+- **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time
11+- **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically
12+- **Multi-transport** — stdio, streamable-http, and SSE transports
13+- **Multi-server** — connect to any number of MCP servers simultaneously
14+- **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions
15+- **Tool annotations** — read-only, destructive, idempotent hints surfaced to LLM
16+- **Configurable timeouts** — per-server connect, request, and idle timeouts
17+- **Custom headers** — auth tokens and other headers for HTTP servers
18+- **Slash commands** — `/mcp` to check status, start/stop/restart servers
19+- **Better error messages** — context-aware errors with actionable suggestions
20+
21+## Quick Start
22+
23+1. **Build the extension:**
24+
25+ ```bash
26+ cd examples/extensions/mcp-bridge
27+ go build -o mcp-bridge .
28+ ```
29+
30+2. **Create a project config file:**
31+
32+ ```bash
33+ mkdir -p .zot
34+ cat > .zot/mcp.json << 'EOF'
35+ {
36+ "mcpServers": {
37+ "filesystem": {
38+ "command": "npx",
39+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
40+ },
41+ "context7": {
42+ "command": "npx",
43+ "args": ["-y", "@upstash/context7-mcp@latest"]
44+ }
45+ }
46+ }
47+ EOF
48+ ```
49+
50+3. **Install the extension:**
51+
52+ ```bash
53+ zot ext install .
54+ ```
55+
56+4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately.
57+
58+## Configuration
59+
60+Config files are loaded from two locations (project overrides global per-server):
61+
62+| Location | Scope |
63+|---|---|
64+| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |
65+| `.zot/mcp.json` | Project-level (in your project root) |
66+
67+### Config Format
68+
69+Standard MCP config — same as Claude Desktop, with zot-specific extensions:
70+
71+```jsonc
72+{
73+ "mcpServers": {
74+ // ── Stdio transport (local subprocess) ───────────────────────────────────
75+ "filesystem": {
76+ "command": "npx", // executable to spawn
77+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
78+ "env": { // extra environment variables
79+ "NODE_ENV": "production"
80+ },
81+ "connectTimeout": 30, // connection timeout (seconds)
82+ "requestTimeout": 60, // per-request timeout (seconds)
83+ "idleTimeout": 300 // idle timeout before stopping (seconds)
84+ },
85+
86+ // ── Streamable HTTP transport (modern HTTP) ─────────────────────────────
87+ "supabase": {
88+ "transport": "streamable-http",
89+ "url": "https://mcp.supabase.com/mcp",
90+ "headers": { // custom HTTP headers
91+ "Authorization": "Bearer YOUR_TOKEN"
92+ }
93+ },
94+
95+ // ── SSE transport (legacy HTTP) ─────────────────────────────────────────
96+ "legacy-server": {
97+ "transport": "sse",
98+ "url": "https://example.com/sse"
99+ }
100+ }
101+}
102+```
103+
104+### Configuration Options
105+
106+| Field | Type | Default | Description |
107+|---|---|---|---|
108+| `command` | string | — | Executable to spawn (stdio only) |
109+| `args` | string[] | [] | Arguments for the command |
110+| `env` | object | — | Extra environment variables |
111+| `transport` | string | "stdio" | Transport: "stdio", "streamable-http", or "sse" |
112+| `url` | string | — | Server URL (HTTP transports only) |
113+| `headers` | object | — | Custom HTTP headers (HTTP transports only) |
114+| `connectTimeout` | number | 30 | Connection timeout in seconds |
115+| `requestTimeout` | number | 60 | Per-request timeout in seconds |
116+| `idleTimeout` | number | 300 | Idle timeout before stopping in seconds |
117+
118+### Example: Multiple Servers
119+
120+```jsonc
121+{
122+ "mcpServers": {
123+ // Filesystem access (stdio)
124+ "filesystem": {
125+ "command": "npx",
126+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
127+ },
128+
129+ // Grep.app - Search GitHub (streamable-http)
130+ "grep": {
131+ "transport": "streamable-http",
132+ "url": "https://mcp.grep.app/"
133+ },
134+
135+ // Database queries (stdio)
136+ "sqlite": {
137+ "command": "npx",
138+ "args": ["-y", "@modelcontextprotocol/server-sqlite", "test.db"]
139+ },
140+
141+ // Documentation lookup (stdio)
142+ "context7": {
143+ "command": "npx",
144+ "args": ["-y", "@upstash/context7-mcp@latest"]
145+ }
146+ }
147+}
148+```
149+
150+## How It Works
151+
152+```
153+┌──────────────────────────────────────────────────────────────┐
154+│ zot agent │
155+│ │
156+│ ┌──────────┐ tool_call ┌──────────────┐ │
157+│ │ LLM │───────────────▶│ mcp-bridge │ │
158+│ │ │◀───────────────│ (extension) │ │
159+│ └──────────┘ tool_result └──────┬───────┘ │
160+│ │ │
161+│ ┌──────────┼──────────┐ │
162+│ ▼ ▼ ▼ │
163+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
164+│ │ MCP │ │ MCP │ │ MCP │ │
165+│ │ server 1 │ │ server 2 │ │ server 3 │ │
166+│ │ (stdio) │ │ (stdio) │ │ (stdio) │ │
167+│ └──────────┘ └──────────┘ └──────────┘ │
168+└──────────────────────────────────────────────────────────────┘
169+```
170+
171+1. **Startup**: mcp-bridge reads config and registers tools from `mcp-tools-cache.json`
172+2. **Background refresh**: starts configured MCP servers, calls `tools/list`, and updates the cache when definitions change
173+3. **Reload**: if the cache changed, run `/reload-ext` once so zot rebuilds the tool registry with the new definitions
174+4. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)
175+5. **Idle timeout**: servers not used for 5 minutes are automatically stopped
176+6. **Auto-respawn**: calling a tool on a stopped server wakes it up
177+7. **Routing**: tool calls are forwarded to the appropriate MCP server
178+
179+## Slash Commands
180+
181+| Command | Description |
182+|---|---|
183+| `/mcp` | Show status of all configured servers |
184+| `/mcp help` | Show available MCP commands |
185+| `/mcp <name>` | Show detailed status for one server |
186+| `/mcp start <name>` | Manually start a server |
187+| `/mcp stop <name>` | Manually stop a server |
188+| `/mcp restart` | Restart all servers |
189+| `/mcp start all` | Manually start all servers |
190+| `/mcp stop all` | Manually stop all servers |
191+| `/mcp setup templates` | Show available setup templates |
192+| `/mcp setup add <template> [--global|--project] [--name <server-name>]` | Add a server from a template |
193+
194+## Tool Naming
195+
196+Tools are namespaced to avoid collisions with zot's built-in tools:
197+
198+```
199+mcp__<server>__<tool>
200+```
201+
202+Examples:
203+- `mcp__filesystem__read_file`
204+- `mcp__filesystem__write_file`
205+- `mcp__sqlite__query`
206+- `mcp__context7__resolve-library-id`
207+
208+Server and tool names are sanitized (non-alphanumeric characters become `_`).
209+
210+## Smart Lazy Loading
211+
212+The bridge uses a "smart lazy" strategy:
213+
214+1. **On startup**: cached tool definitions are registered without blocking zot startup
215+2. **In the background**: servers start long enough to refresh the tool cache
216+3. **During use**: servers stay running for fast tool calls
217+4. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)
218+5. **On next tool call**: the server is respawned automatically (~1-3s delay)
219+
220+This gives you:
221+- Cached tools visible to the LLM immediately
222+- Fast tool calls when actively working
223+- Memory freed when not using MCP tools
224+- One manual `/reload-ext` only when tool definitions change
225+
226+## Troubleshooting
227+
228+**Check server status:**
229+```
230+/mcp
231+```
232+
233+**View extension logs:**
234+```bash
235+zot ext logs mcp -f
236+```
237+
238+**Common issues:**
239+
240+- **Server fails to start**: check that `command` exists in your PATH, or use absolute path
241+- **Tool not found**: run `/mcp` to see if the server started successfully
242+- **Slow first call**: server is respawning after idle timeout (normal)
243+
244+## Limitations
245+
246+- **No OAuth flow** — authentication requires manual token configuration in headers
247+- **No resources/prompts** — only tools are bridged (MCP resources and prompts coming later)
248+- **No automatic config hot reload** — run `/reload-ext` after setup/config changes
249+
250+## Development
251+
252+```bash
253+# Build
254+cd examples/extensions/mcp-bridge
255+go build -o mcp-bridge .
256+
257+# Test
258+go test ./...
259+go vet ./...
260+
261+# Run without installing (for one zot session)
262+zot --ext .
263+
264+# View logs
265+zot ext logs mcp -f
266+```
267+
268+## License
269+
270+MIT
271+
272+## Testing
273+
274+```bash
275+go test ./...
276+go vet ./...
277+go build -o /tmp/mcp-bridge .
278+```
279+
280+Tested MCP servers:
281+
282+| Server | Transport | Result |
283+|---|---|---|
284+| `@modelcontextprotocol/server-filesystem` | stdio | 14 tools registered; file operations and MCP errors handled correctly |
285+| grep.app `https://mcp.grep.app/` | streamable-http | `searchGitHub` registered and successfully searched public GitHub code |
286+
287+Note: grep.app uses the root endpoint `/`. Streamable HTTP protocol headers are handled automatically by the bridge and should not be written to `mcp.json`.
288+
@@ -0,0 +1,288 @@
1+# mcp-bridge
2+
3+Connect zot to [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers.
4+
5+This extension reads MCP server configurations from standard locations (same format as Claude Desktop, Cursor, Cline, etc.) and bridges their tools into zot so the LLM can call them directly.
6+
7+## Features
8+
9+- **Standard config format** — same JSON as Claude Desktop, Cursor, Cline
10+- **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time
11+- **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically
12+- **Multi-transport** — stdio, streamable-http, and SSE transports
13+- **Multi-server** — connect to any number of MCP servers simultaneously
14+- **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions
15+- **Tool annotations** — read-only, destructive, idempotent hints surfaced to LLM
16+- **Configurable timeouts** — per-server connect, request, and idle timeouts
17+- **Custom headers** — auth tokens and other headers for HTTP servers
18+- **Slash commands** — `/mcp` to check status, start/stop/restart servers
19+- **Better error messages** — context-aware errors with actionable suggestions
20+
21+## Quick Start
22+
23+1. **Build the extension:**
24+
25+ ```bash
26+ cd examples/extensions/mcp-bridge
27+ go build -o mcp-bridge .
28+ ```
29+
30+2. **Create a project config file:**
31+
32+ ```bash
33+ mkdir -p .zot
34+ cat > .zot/mcp.json << 'EOF'
35+ {
36+ "mcpServers": {
37+ "filesystem": {
38+ "command": "npx",
39+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
40+ },
41+ "context7": {
42+ "command": "npx",
43+ "args": ["-y", "@upstash/context7-mcp@latest"]
44+ }
45+ }
46+ }
47+ EOF
48+ ```
49+
50+3. **Install the extension:**
51+
52+ ```bash
53+ zot ext install .
54+ ```
55+
56+4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately.
57+
58+## Configuration
59+
60+Config files are loaded from two locations (project overrides global per-server):
61+
62+| Location | Scope |
63+|---|---|
64+| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |
65+| `.zot/mcp.json` | Project-level (in your project root) |
66+
67+### Config Format
68+
69+Standard MCP config — same as Claude Desktop, with zot-specific extensions:
70+
71+```jsonc
72+{
73+ "mcpServers": {
74+ // ── Stdio transport (local subprocess) ───────────────────────────────────
75+ "filesystem": {
76+ "command": "npx", // executable to spawn
77+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
78+ "env": { // extra environment variables
79+ "NODE_ENV": "production"
80+ },
81+ "connectTimeout": 30, // connection timeout (seconds)
82+ "requestTimeout": 60, // per-request timeout (seconds)
83+ "idleTimeout": 300 // idle timeout before stopping (seconds)
84+ },
85+
86+ // ── Streamable HTTP transport (modern HTTP) ─────────────────────────────
87+ "supabase": {
88+ "transport": "streamable-http",
89+ "url": "https://mcp.supabase.com/mcp",
90+ "headers": { // custom HTTP headers
91+ "Authorization": "Bearer YOUR_TOKEN"
92+ }
93+ },
94+
95+ // ── SSE transport (legacy HTTP) ─────────────────────────────────────────
96+ "legacy-server": {
97+ "transport": "sse",
98+ "url": "https://example.com/sse"
99+ }
100+ }
101+}
102+```
103+
104+### Configuration Options
105+
106+| Field | Type | Default | Description |
107+|---|---|---|---|
108+| `command` | string | — | Executable to spawn (stdio only) |
109+| `args` | string[] | [] | Arguments for the command |
110+| `env` | object | — | Extra environment variables |
111+| `transport` | string | "stdio" | Transport: "stdio", "streamable-http", or "sse" |
112+| `url` | string | — | Server URL (HTTP transports only) |
113+| `headers` | object | — | Custom HTTP headers (HTTP transports only) |
114+| `connectTimeout` | number | 30 | Connection timeout in seconds |
115+| `requestTimeout` | number | 60 | Per-request timeout in seconds |
116+| `idleTimeout` | number | 300 | Idle timeout before stopping in seconds |
117+
118+### Example: Multiple Servers
119+
120+```jsonc
121+{
122+ "mcpServers": {
123+ // Filesystem access (stdio)
124+ "filesystem": {
125+ "command": "npx",
126+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
127+ },
128+
129+ // Grep.app - Search GitHub (streamable-http)
130+ "grep": {
131+ "transport": "streamable-http",
132+ "url": "https://mcp.grep.app/"
133+ },
134+
135+ // Database queries (stdio)
136+ "sqlite": {
137+ "command": "npx",
138+ "args": ["-y", "@modelcontextprotocol/server-sqlite", "test.db"]
139+ },
140+
141+ // Documentation lookup (stdio)
142+ "context7": {
143+ "command": "npx",
144+ "args": ["-y", "@upstash/context7-mcp@latest"]
145+ }
146+ }
147+}
148+```
149+
150+## How It Works
151+
152+```
153+┌──────────────────────────────────────────────────────────────┐
154+│ zot agent │
155+│ │
156+│ ┌──────────┐ tool_call ┌──────────────┐ │
157+│ │ LLM │───────────────▶│ mcp-bridge │ │
158+│ │ │◀───────────────│ (extension) │ │
159+│ └──────────┘ tool_result └──────┬───────┘ │
160+│ │ │
161+│ ┌──────────┼──────────┐ │
162+│ ▼ ▼ ▼ │
163+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
164+│ │ MCP │ │ MCP │ │ MCP │ │
165+│ │ server 1 │ │ server 2 │ │ server 3 │ │
166+│ │ (stdio) │ │ (stdio) │ │ (stdio) │ │
167+│ └──────────┘ └──────────┘ └──────────┘ │
168+└──────────────────────────────────────────────────────────────┘
169+```
170+
171+1. **Startup**: mcp-bridge reads config and registers tools from `mcp-tools-cache.json`
172+2. **Background refresh**: starts configured MCP servers, calls `tools/list`, and updates the cache when definitions change
173+3. **Reload**: if the cache changed, run `/reload-ext` once so zot rebuilds the tool registry with the new definitions
174+4. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`)
175+5. **Idle timeout**: servers not used for 5 minutes are automatically stopped
176+6. **Auto-respawn**: calling a tool on a stopped server wakes it up
177+7. **Routing**: tool calls are forwarded to the appropriate MCP server
178+
179+## Slash Commands
180+
181+| Command | Description |
182+|---|---|
183+| `/mcp` | Show status of all configured servers |
184+| `/mcp help` | Show available MCP commands |
185+| `/mcp <name>` | Show detailed status for one server |
186+| `/mcp start <name>` | Manually start a server |
187+| `/mcp stop <name>` | Manually stop a server |
188+| `/mcp restart` | Restart all servers |
189+| `/mcp start all` | Manually start all servers |
190+| `/mcp stop all` | Manually stop all servers |
191+| `/mcp setup templates` | Show available setup templates |
192+| `/mcp setup add <template> [--global|--project] [--name <server-name>]` | Add a server from a template |
193+
194+## Tool Naming
195+
196+Tools are namespaced to avoid collisions with zot's built-in tools:
197+
198+```
199+mcp__<server>__<tool>
200+```
201+
202+Examples:
203+- `mcp__filesystem__read_file`
204+- `mcp__filesystem__write_file`
205+- `mcp__sqlite__query`
206+- `mcp__context7__resolve-library-id`
207+
208+Server and tool names are sanitized (non-alphanumeric characters become `_`).
209+
210+## Smart Lazy Loading
211+
212+The bridge uses a "smart lazy" strategy:
213+
214+1. **On startup**: cached tool definitions are registered without blocking zot startup
215+2. **In the background**: servers start long enough to refresh the tool cache
216+3. **During use**: servers stay running for fast tool calls
217+4. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU)
218+5. **On next tool call**: the server is respawned automatically (~1-3s delay)
219+
220+This gives you:
221+- Cached tools visible to the LLM immediately
222+- Fast tool calls when actively working
223+- Memory freed when not using MCP tools
224+- One manual `/reload-ext` only when tool definitions change
225+
226+## Troubleshooting
227+
228+**Check server status:**
229+```
230+/mcp
231+```
232+
233+**View extension logs:**
234+```bash
235+zot ext logs mcp -f
236+```
237+
238+**Common issues:**
239+
240+- **Server fails to start**: check that `command` exists in your PATH, or use absolute path
241+- **Tool not found**: run `/mcp` to see if the server started successfully
242+- **Slow first call**: server is respawning after idle timeout (normal)
243+
244+## Limitations
245+
246+- **No OAuth flow** — authentication requires manual token configuration in headers
247+- **No resources/prompts** — only tools are bridged (MCP resources and prompts coming later)
248+- **No automatic config hot reload** — run `/reload-ext` after setup/config changes
249+
250+## Development
251+
252+```bash
253+# Build
254+cd examples/extensions/mcp-bridge
255+go build -o mcp-bridge .
256+
257+# Test
258+go test ./...
259+go vet ./...
260+
261+# Run without installing (for one zot session)
262+zot --ext .
263+
264+# View logs
265+zot ext logs mcp -f
266+```
267+
268+## License
269+
270+MIT
271+
272+## Testing
273+
274+```bash
275+go test ./...
276+go vet ./...
277+go build -o /tmp/mcp-bridge .
278+```
279+
280+Tested MCP servers:
281+
282+| Server | Transport | Result |
283+|---|---|---|
284+| `@modelcontextprotocol/server-filesystem` | stdio | 14 tools registered; file operations and MCP errors handled correctly |
285+| grep.app `https://mcp.grep.app/` | streamable-http | `searchGitHub` registered and successfully searched public GitHub code |
286+
287+Note: grep.app uses the root endpoint `/`. Streamable HTTP protocol headers are handled automatically by the bridge and should not be written to `mcp.json`.
288+
A examples/extensions/mcp-bridge/bridge.go +447 -0
@@ -0,0 +1,447 @@
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).
9+package main
10+
11+import (
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.
28+type 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.
34+type bridge struct {
35+ e *ext.Extension
36+ // servers is written once in loadServers before any goroutine
37+ // starts, and read-only afterwards — that invariant is what makes
38+ // the lock-free reads in handleToolCall and the idle reaper safe.
39+ // Do not add or remove entries after startup.
40+ servers map[string]*managedServer
41+ mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
42+ logger *log.Logger
43+
44+ mu sync.Mutex // guards mapping
45+}
46+
47+// newBridge creates a new MCP→zot bridge.
48+func newBridge(e *ext.Extension, logger *log.Logger) *bridge {
49+ return &bridge{
50+ e: e,
51+ servers: make(map[string]*managedServer),
52+ mapping: make(map[string]toolMapping),
53+ logger: logger,
54+ }
55+}
56+
57+// sanitizeName converts a string into a valid zot tool name component.
58+// Zot tool names must match [a-zA-Z][a-zA-Z0-9_]*.
59+var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]`)
60+
61+func sanitizeName(s string) string {
62+ s = invalidChars.ReplaceAllString(s, "_")
63+ // Ensure it starts with a letter
64+ if len(s) > 0 && (s[0] >= '0' && s[0] <= '9') {
65+ s = "t_" + s
66+ }
67+ if s == "" {
68+ s = "unnamed"
69+ }
70+ return s
71+}
72+
73+// toolName builds the namespaced zot tool name for an MCP tool.
74+func toolName(serverName, mcpToolName string) string {
75+ return fmt.Sprintf("mcp__%s__%s", sanitizeName(serverName), sanitizeName(mcpToolName))
76+}
77+
78+// loadServers reads the config and creates managed servers.
79+func (b *bridge) loadServers(cfg Config) {
80+ for name, srvCfg := range cfg.MCPServers {
81+ srv := newManagedServer(name, srvCfg, b.logger)
82+ b.servers[name] = srv
83+ }
84+}
85+
86+// registerCachedTools registers previously discovered tool definitions without
87+// starting MCP servers. Tool calls still lazy-start the owning server on demand.
88+func (b *bridge) registerCachedTools(cache toolCache) int {
89+ count := 0
90+ for serverName, srv := range b.servers {
91+ cached, ok := cache.Servers[serverName]
92+ if !ok || cached.Fingerprint != serverFingerprint(srv.config) {
93+ continue
94+ }
95+ cachedNames := make([]mcp.Tool, 0, len(cached.Tools))
96+ for _, tool := range cached.Tools {
97+ b.registerCachedTool(serverName, tool)
98+ cachedNames = append(cachedNames, mcp.Tool{Name: tool.Name})
99+ count++
100+ }
101+ srv.mu.Lock()
102+ if len(srv.tools) == 0 {
103+ srv.tools = cachedNames
104+ }
105+ srv.mu.Unlock()
106+ }
107+ return count
108+}
109+
110+// refreshToolCache discovers live tools and updates the on-disk cache.
111+// It reports whether the cache changed; callers should only ask for /reload-ext
112+// when changed is true.
113+func (b *bridge) refreshToolCache(ctx context.Context, path string) (bool, error) {
114+ previous, err := readToolCache(path)
115+ if err != nil {
116+ b.logger.Printf("read existing tool cache: %v", err)
117+ }
118+ cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
119+ var wg sync.WaitGroup
120+ errCh := make(chan error, len(b.servers))
121+ var mu sync.Mutex
122+
123+ for name, srv := range b.servers {
124+ wg.Add(1)
125+ go func(n string, s *managedServer) {
126+ defer wg.Done()
127+ if err := s.start(ctx); err != nil {
128+ b.logger.Printf("[%s] lazy discovery failed: %v", n, err)
129+ errCh <- fmt.Errorf("%s: %w", n, err)
130+ return
131+ }
132+
133+ s.mu.Lock()
134+ tools := append([]mcp.Tool(nil), s.tools...)
135+ s.mu.Unlock()
136+
137+ cachedTools := make([]cachedTool, 0, len(tools))
138+ for _, tool := range tools {
139+ ct, err := cachedToolFromMCP(n, tool)
140+ if err != nil {
141+ b.logger.Printf("[%s] tool %s: schema marshal error: %v", n, tool.Name, err)
142+ continue
143+ }
144+ cachedTools = append(cachedTools, ct)
145+ }
146+
147+ sort.Slice(cachedTools, func(i, j int) bool { return cachedTools[i].Name < cachedTools[j].Name })
148+
149+ mu.Lock()
150+ cache.Servers[n] = cachedServer{Fingerprint: serverFingerprint(s.config), Tools: cachedTools}
151+ mu.Unlock()
152+ }(name, srv)
153+ }
154+
155+ wg.Wait()
156+ close(errCh)
157+
158+ changed := !toolCachesEqual(previous, cache)
159+ if changed {
160+ if err := writeToolCache(path, cache); err != nil {
161+ return false, err
162+ }
163+ }
164+
165+ var errs []string
166+ for err := range errCh {
167+ errs = append(errs, err.Error())
168+ }
169+ if len(errs) > 0 {
170+ return changed, fmt.Errorf("%s", strings.Join(errs, "; "))
171+ }
172+ return changed, nil
173+}
174+
175+func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
176+ zotName := toolName(serverName, tool.Name)
177+
178+ b.mu.Lock()
179+ if existing, exists := b.mapping[zotName]; exists {
180+ b.mu.Unlock()
181+ if existing.serverName != serverName || existing.mcpTool != tool.Name {
182+ b.logger.Printf("tool name collision: %s already maps to %s/%s; skipping %s/%s",
183+ zotName, existing.serverName, existing.mcpTool, serverName, tool.Name)
184+ }
185+ return
186+ }
187+ b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name}
188+ b.mu.Unlock()
189+
190+ b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
191+ return b.handleToolCall(zotName, args)
192+ })
193+
194+ b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name)
195+}
196+
197+func mcpToolDescription(serverName string, tool mcp.Tool) string {
198+ desc := tool.Description
199+ if tool.Annotations.Title != "" {
200+ desc = tool.Annotations.Title + ": " + desc
201+ }
202+
203+ var hints []string
204+ if tool.Annotations.ReadOnlyHint != nil && *tool.Annotations.ReadOnlyHint {
205+ hints = append(hints, "read-only")
206+ }
207+ if tool.Annotations.IdempotentHint != nil && *tool.Annotations.IdempotentHint {
208+ hints = append(hints, "idempotent")
209+ }
210+ if tool.Annotations.OpenWorldHint != nil && !*tool.Annotations.OpenWorldHint {
211+ hints = append(hints, "closed-world")
212+ }
213+ if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
214+ hints = append(hints, "destructive")
215+ }
216+ if len(hints) > 0 {
217+ desc += " [" + strings.Join(hints, ", ") + "]"
218+ }
219+ if desc == "" {
220+ desc = fmt.Sprintf("MCP tool from server %q", serverName)
221+ }
222+ return desc
223+}
224+
225+// mcpToolSchema converts an MCP Tool's input schema to a JSON Schema value.
226+func mcpToolSchema(tool mcp.Tool) any {
227+ if len(tool.RawInputSchema) > 0 {
228+ return json.RawMessage(tool.RawInputSchema)
229+ }
230+
231+ schema := map[string]any{
232+ "type": tool.InputSchema.Type,
233+ "properties": tool.InputSchema.Properties,
234+ }
235+ if len(tool.InputSchema.Required) > 0 {
236+ schema["required"] = tool.InputSchema.Required
237+ }
238+ if len(tool.InputSchema.Defs) > 0 {
239+ schema["$defs"] = tool.InputSchema.Defs
240+ }
241+ if tool.InputSchema.AdditionalProperties != nil {
242+ schema["additionalProperties"] = tool.InputSchema.AdditionalProperties
243+ }
244+ return schema
245+}
246+
247+// handleToolCall routes a zot tool call to the appropriate MCP server.
248+func (b *bridge) handleToolCall(zotName string, args json.RawMessage) ext.ToolResult {
249+ b.mu.Lock()
250+ mapping, ok := b.mapping[zotName]
251+ b.mu.Unlock()
252+
253+ if !ok {
254+ return ext.TextErrorResult(fmt.Sprintf(
255+ "Tool '%s' not found. This tool was registered but is no longer available. "+
256+ "The MCP server may have been stopped. Try running '/mcp' to check server status.",
257+ zotName))
258+ }
259+
260+ srv, ok := b.servers[mapping.serverName]
261+ if !ok {
262+ return ext.TextErrorResult(fmt.Sprintf(
263+ "MCP server '%s' not found. The server configuration may have been removed. "+
264+ "Check your mcp.json configuration file.",
265+ mapping.serverName))
266+ }
267+
268+ // managedServer owns all timeouts (connect + request); pass a
269+ // plain context here.
270+ result, err := srv.callTool(context.Background(), mapping.mcpTool, args)
271+ if err != nil {
272+ if errors.Is(err, context.DeadlineExceeded) {
273+ return ext.TextErrorResult(timeoutErrorMessage(mapping.serverName, srv.config.RequestTimeout))
274+ }
275+ errMsg := err.Error()
276+ if strings.Contains(errMsg, "connection") || strings.Contains(errMsg, "transport") {
277+ return ext.TextErrorResult(fmt.Sprintf(
278+ "Connection to MCP server '%s' failed: %v. The server may have crashed or been stopped. "+
279+ "Try running '/mcp start %s' to restart that server, or '/mcp restart' to restart all servers.",
280+ mapping.serverName, err, mapping.serverName))
281+ }
282+ return ext.TextErrorResult(fmt.Sprintf(
283+ "MCP tool call failed: %v. Server: %s, Tool: %s. "+
284+ "Check '/mcp %s' for server status.",
285+ err, mapping.serverName, mapping.mcpTool, mapping.serverName))
286+ }
287+
288+ // Convert MCP result to zot ToolResult
289+ return mcpResultToZot(result)
290+}
291+
292+// timeoutErrorMessage formats the user-facing text for a deadline error.
293+func timeoutErrorMessage(serverName string, requestTimeout int) string {
294+ return fmt.Sprintf(
295+ "Tool call timed out after %d seconds. The MCP server '%s' may be slow or unresponsive. "+
296+ "You can increase the timeout in your mcp.json config with 'requestTimeout'.",
297+ requestTimeout, serverName)
298+}
299+
300+// mcpResultToZot converts an MCP CallToolResult to a zot ToolResult.
301+func mcpResultToZot(result *mcp.CallToolResult) ext.ToolResult {
302+ if result == nil {
303+ return ext.TextResult("(no result)")
304+ }
305+
306+ var contents []ext.ToolContent
307+ for _, c := range result.Content {
308+ switch v := c.(type) {
309+ case mcp.TextContent:
310+ contents = append(contents, ext.Text(v.Text))
311+ case mcp.ImageContent:
312+ contents = append(contents, ext.Image(v.MIMEType, v.Data))
313+ default:
314+ // Fallback: try to marshal as JSON
315+ data, _ := json.Marshal(v)
316+ contents = append(contents, ext.Text(string(data)))
317+ }
318+ }
319+
320+ if len(contents) == 0 {
321+ contents = append(contents, ext.Text("(empty result)"))
322+ }
323+
324+ tr := ext.ToolResult{Content: contents}
325+ if result.IsError {
326+ tr.IsError = true
327+ }
328+ return tr
329+}
330+
331+// startAll starts all configured servers without re-registering their tools with zot.
332+func (b *bridge) startAll(ctx context.Context) error {
333+ var wg sync.WaitGroup
334+ errCh := make(chan error, len(b.servers))
335+
336+ for name, srv := range b.servers {
337+ wg.Add(1)
338+ go func(n string, s *managedServer) {
339+ defer wg.Done()
340+ if err := s.start(ctx); err != nil {
341+ b.logger.Printf("[%s] failed to start: %v", n, err)
342+ errCh <- fmt.Errorf("%s: %w", n, err)
343+ }
344+ }(name, srv)
345+ }
346+
347+ wg.Wait()
348+ close(errCh)
349+
350+ var errs []string
351+ for err := range errCh {
352+ errs = append(errs, err.Error())
353+ }
354+ if len(errs) > 0 {
355+ return fmt.Errorf("%s", strings.Join(errs, "; "))
356+ }
357+ return nil
358+}
359+
360+// startIdleReaper runs a background goroutine that kills idle servers.
361+// It has no shutdown mechanism by design: it lives exactly as long as
362+// the extension process, and zot reaps the whole process on exit.
363+func (b *bridge) startIdleReaper() {
364+ go func() {
365+ ticker := time.NewTicker(30 * time.Second)
366+ defer ticker.Stop()
367+ for range ticker.C {
368+ for _, srv := range b.servers {
369+ // Use per-server idle timeout
370+ idleTimeout := time.Duration(srv.config.IdleTimeout) * time.Second
371+ if srv.isIdle(idleTimeout) {
372+ b.logger.Printf("[%s] idle timeout, stopping", srv.name)
373+ srv.stop()
374+ }
375+ }
376+ }
377+ }()
378+}
379+
380+// stopAll shuts down all MCP servers.
381+func (b *bridge) stopAll() {
382+ for _, srv := range b.servers {
383+ srv.stop()
384+ }
385+}
386+
387+// serverStatus returns compact status info for all servers in stable order.
388+func (b *bridge) serverStatus() []string {
389+ names := make([]string, 0, len(b.servers))
390+ for name := range b.servers {
391+ names = append(names, name)
392+ }
393+ sort.Strings(names)
394+
395+ lines := make([]string, 0, len(names))
396+ for _, name := range names {
397+ lines = append(lines, b.servers[name].status())
398+ }
399+ return lines
400+}
401+
402+func (b *bridge) notifyLevel() string {
403+ if len(b.servers) == 0 {
404+ return "info"
405+ }
406+ ready := 0
407+ errored := 0
408+ for _, srv := range b.servers {
409+ srv.mu.Lock()
410+ state := srv.state
411+ srv.mu.Unlock()
412+ switch state {
413+ case stateReady:
414+ ready++
415+ case stateError:
416+ errored++
417+ }
418+ }
419+ if errored > 0 && ready == 0 {
420+ return "error"
421+ }
422+ if errored > 0 {
423+ return "warn"
424+ }
425+ return "success"
426+}
427+
428+// startServer manually starts a specific server.
429+func (b *bridge) startServer(name string) error {
430+ srv, ok := b.servers[name]
431+ if !ok {
432+ return fmt.Errorf("unknown server: %s", name)
433+ }
434+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
435+ defer cancel()
436+ return srv.start(ctx)
437+}
438+
439+// stopServer manually stops a specific server.
440+func (b *bridge) stopServer(name string) error {
441+ srv, ok := b.servers[name]
442+ if !ok {
443+ return fmt.Errorf("unknown server: %s", name)
444+ }
445+ srv.stop()
446+ return nil
447+}
@@ -0,0 +1,447 @@
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).
9+package main
10+
11+import (
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.
28+type 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.
34+type bridge struct {
35+ e *ext.Extension
36+ // servers is written once in loadServers before any goroutine
37+ // starts, and read-only afterwards — that invariant is what makes
38+ // the lock-free reads in handleToolCall and the idle reaper safe.
39+ // Do not add or remove entries after startup.
40+ servers map[string]*managedServer
41+ mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
42+ logger *log.Logger
43+
44+ mu sync.Mutex // guards mapping
45+}
46+
47+// newBridge creates a new MCP→zot bridge.
48+func newBridge(e *ext.Extension, logger *log.Logger) *bridge {
49+ return &bridge{
50+ e: e,
51+ servers: make(map[string]*managedServer),
52+ mapping: make(map[string]toolMapping),
53+ logger: logger,
54+ }
55+}
56+
57+// sanitizeName converts a string into a valid zot tool name component.
58+// Zot tool names must match [a-zA-Z][a-zA-Z0-9_]*.
59+var invalidChars = regexp.MustCompile(`[^a-zA-Z0-9]`)
60+
61+func sanitizeName(s string) string {
62+ s = invalidChars.ReplaceAllString(s, "_")
63+ // Ensure it starts with a letter
64+ if len(s) > 0 && (s[0] >= '0' && s[0] <= '9') {
65+ s = "t_" + s
66+ }
67+ if s == "" {
68+ s = "unnamed"
69+ }
70+ return s
71+}
72+
73+// toolName builds the namespaced zot tool name for an MCP tool.
74+func toolName(serverName, mcpToolName string) string {
75+ return fmt.Sprintf("mcp__%s__%s", sanitizeName(serverName), sanitizeName(mcpToolName))
76+}
77+
78+// loadServers reads the config and creates managed servers.
79+func (b *bridge) loadServers(cfg Config) {
80+ for name, srvCfg := range cfg.MCPServers {
81+ srv := newManagedServer(name, srvCfg, b.logger)
82+ b.servers[name] = srv
83+ }
84+}
85+
86+// registerCachedTools registers previously discovered tool definitions without
87+// starting MCP servers. Tool calls still lazy-start the owning server on demand.
88+func (b *bridge) registerCachedTools(cache toolCache) int {
89+ count := 0
90+ for serverName, srv := range b.servers {
91+ cached, ok := cache.Servers[serverName]
92+ if !ok || cached.Fingerprint != serverFingerprint(srv.config) {
93+ continue
94+ }
95+ cachedNames := make([]mcp.Tool, 0, len(cached.Tools))
96+ for _, tool := range cached.Tools {
97+ b.registerCachedTool(serverName, tool)
98+ cachedNames = append(cachedNames, mcp.Tool{Name: tool.Name})
99+ count++
100+ }
101+ srv.mu.Lock()
102+ if len(srv.tools) == 0 {
103+ srv.tools = cachedNames
104+ }
105+ srv.mu.Unlock()
106+ }
107+ return count
108+}
109+
110+// refreshToolCache discovers live tools and updates the on-disk cache.
111+// It reports whether the cache changed; callers should only ask for /reload-ext
112+// when changed is true.
113+func (b *bridge) refreshToolCache(ctx context.Context, path string) (bool, error) {
114+ previous, err := readToolCache(path)
115+ if err != nil {
116+ b.logger.Printf("read existing tool cache: %v", err)
117+ }
118+ cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
119+ var wg sync.WaitGroup
120+ errCh := make(chan error, len(b.servers))
121+ var mu sync.Mutex
122+
123+ for name, srv := range b.servers {
124+ wg.Add(1)
125+ go func(n string, s *managedServer) {
126+ defer wg.Done()
127+ if err := s.start(ctx); err != nil {
128+ b.logger.Printf("[%s] lazy discovery failed: %v", n, err)
129+ errCh <- fmt.Errorf("%s: %w", n, err)
130+ return
131+ }
132+
133+ s.mu.Lock()
134+ tools := append([]mcp.Tool(nil), s.tools...)
135+ s.mu.Unlock()
136+
137+ cachedTools := make([]cachedTool, 0, len(tools))
138+ for _, tool := range tools {
139+ ct, err := cachedToolFromMCP(n, tool)
140+ if err != nil {
141+ b.logger.Printf("[%s] tool %s: schema marshal error: %v", n, tool.Name, err)
142+ continue
143+ }
144+ cachedTools = append(cachedTools, ct)
145+ }
146+
147+ sort.Slice(cachedTools, func(i, j int) bool { return cachedTools[i].Name < cachedTools[j].Name })
148+
149+ mu.Lock()
150+ cache.Servers[n] = cachedServer{Fingerprint: serverFingerprint(s.config), Tools: cachedTools}
151+ mu.Unlock()
152+ }(name, srv)
153+ }
154+
155+ wg.Wait()
156+ close(errCh)
157+
158+ changed := !toolCachesEqual(previous, cache)
159+ if changed {
160+ if err := writeToolCache(path, cache); err != nil {
161+ return false, err
162+ }
163+ }
164+
165+ var errs []string
166+ for err := range errCh {
167+ errs = append(errs, err.Error())
168+ }
169+ if len(errs) > 0 {
170+ return changed, fmt.Errorf("%s", strings.Join(errs, "; "))
171+ }
172+ return changed, nil
173+}
174+
175+func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
176+ zotName := toolName(serverName, tool.Name)
177+
178+ b.mu.Lock()
179+ if existing, exists := b.mapping[zotName]; exists {
180+ b.mu.Unlock()
181+ if existing.serverName != serverName || existing.mcpTool != tool.Name {
182+ b.logger.Printf("tool name collision: %s already maps to %s/%s; skipping %s/%s",
183+ zotName, existing.serverName, existing.mcpTool, serverName, tool.Name)
184+ }
185+ return
186+ }
187+ b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name}
188+ b.mu.Unlock()
189+
190+ b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
191+ return b.handleToolCall(zotName, args)
192+ })
193+
194+ b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name)
195+}
196+
197+func mcpToolDescription(serverName string, tool mcp.Tool) string {
198+ desc := tool.Description
199+ if tool.Annotations.Title != "" {
200+ desc = tool.Annotations.Title + ": " + desc
201+ }
202+
203+ var hints []string
204+ if tool.Annotations.ReadOnlyHint != nil && *tool.Annotations.ReadOnlyHint {
205+ hints = append(hints, "read-only")
206+ }
207+ if tool.Annotations.IdempotentHint != nil && *tool.Annotations.IdempotentHint {
208+ hints = append(hints, "idempotent")
209+ }
210+ if tool.Annotations.OpenWorldHint != nil && !*tool.Annotations.OpenWorldHint {
211+ hints = append(hints, "closed-world")
212+ }
213+ if tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint {
214+ hints = append(hints, "destructive")
215+ }
216+ if len(hints) > 0 {
217+ desc += " [" + strings.Join(hints, ", ") + "]"
218+ }
219+ if desc == "" {
220+ desc = fmt.Sprintf("MCP tool from server %q", serverName)
221+ }
222+ return desc
223+}
224+
225+// mcpToolSchema converts an MCP Tool's input schema to a JSON Schema value.
226+func mcpToolSchema(tool mcp.Tool) any {
227+ if len(tool.RawInputSchema) > 0 {
228+ return json.RawMessage(tool.RawInputSchema)
229+ }
230+
231+ schema := map[string]any{
232+ "type": tool.InputSchema.Type,
233+ "properties": tool.InputSchema.Properties,
234+ }
235+ if len(tool.InputSchema.Required) > 0 {
236+ schema["required"] = tool.InputSchema.Required
237+ }
238+ if len(tool.InputSchema.Defs) > 0 {
239+ schema["$defs"] = tool.InputSchema.Defs
240+ }
241+ if tool.InputSchema.AdditionalProperties != nil {
242+ schema["additionalProperties"] = tool.InputSchema.AdditionalProperties
243+ }
244+ return schema
245+}
246+
247+// handleToolCall routes a zot tool call to the appropriate MCP server.
248+func (b *bridge) handleToolCall(zotName string, args json.RawMessage) ext.ToolResult {
249+ b.mu.Lock()
250+ mapping, ok := b.mapping[zotName]
251+ b.mu.Unlock()
252+
253+ if !ok {
254+ return ext.TextErrorResult(fmt.Sprintf(
255+ "Tool '%s' not found. This tool was registered but is no longer available. "+
256+ "The MCP server may have been stopped. Try running '/mcp' to check server status.",
257+ zotName))
258+ }
259+
260+ srv, ok := b.servers[mapping.serverName]
261+ if !ok {
262+ return ext.TextErrorResult(fmt.Sprintf(
263+ "MCP server '%s' not found. The server configuration may have been removed. "+
264+ "Check your mcp.json configuration file.",
265+ mapping.serverName))
266+ }
267+
268+ // managedServer owns all timeouts (connect + request); pass a
269+ // plain context here.
270+ result, err := srv.callTool(context.Background(), mapping.mcpTool, args)
271+ if err != nil {
272+ if errors.Is(err, context.DeadlineExceeded) {
273+ return ext.TextErrorResult(timeoutErrorMessage(mapping.serverName, srv.config.RequestTimeout))
274+ }
275+ errMsg := err.Error()
276+ if strings.Contains(errMsg, "connection") || strings.Contains(errMsg, "transport") {
277+ return ext.TextErrorResult(fmt.Sprintf(
278+ "Connection to MCP server '%s' failed: %v. The server may have crashed or been stopped. "+
279+ "Try running '/mcp start %s' to restart that server, or '/mcp restart' to restart all servers.",
280+ mapping.serverName, err, mapping.serverName))
281+ }
282+ return ext.TextErrorResult(fmt.Sprintf(
283+ "MCP tool call failed: %v. Server: %s, Tool: %s. "+
284+ "Check '/mcp %s' for server status.",
285+ err, mapping.serverName, mapping.mcpTool, mapping.serverName))
286+ }
287+
288+ // Convert MCP result to zot ToolResult
289+ return mcpResultToZot(result)
290+}
291+
292+// timeoutErrorMessage formats the user-facing text for a deadline error.
293+func timeoutErrorMessage(serverName string, requestTimeout int) string {
294+ return fmt.Sprintf(
295+ "Tool call timed out after %d seconds. The MCP server '%s' may be slow or unresponsive. "+
296+ "You can increase the timeout in your mcp.json config with 'requestTimeout'.",
297+ requestTimeout, serverName)
298+}
299+
300+// mcpResultToZot converts an MCP CallToolResult to a zot ToolResult.
301+func mcpResultToZot(result *mcp.CallToolResult) ext.ToolResult {
302+ if result == nil {
303+ return ext.TextResult("(no result)")
304+ }
305+
306+ var contents []ext.ToolContent
307+ for _, c := range result.Content {
308+ switch v := c.(type) {
309+ case mcp.TextContent:
310+ contents = append(contents, ext.Text(v.Text))
311+ case mcp.ImageContent:
312+ contents = append(contents, ext.Image(v.MIMEType, v.Data))
313+ default:
314+ // Fallback: try to marshal as JSON
315+ data, _ := json.Marshal(v)
316+ contents = append(contents, ext.Text(string(data)))
317+ }
318+ }
319+
320+ if len(contents) == 0 {
321+ contents = append(contents, ext.Text("(empty result)"))
322+ }
323+
324+ tr := ext.ToolResult{Content: contents}
325+ if result.IsError {
326+ tr.IsError = true
327+ }
328+ return tr
329+}
330+
331+// startAll starts all configured servers without re-registering their tools with zot.
332+func (b *bridge) startAll(ctx context.Context) error {
333+ var wg sync.WaitGroup
334+ errCh := make(chan error, len(b.servers))
335+
336+ for name, srv := range b.servers {
337+ wg.Add(1)
338+ go func(n string, s *managedServer) {
339+ defer wg.Done()
340+ if err := s.start(ctx); err != nil {
341+ b.logger.Printf("[%s] failed to start: %v", n, err)
342+ errCh <- fmt.Errorf("%s: %w", n, err)
343+ }
344+ }(name, srv)
345+ }
346+
347+ wg.Wait()
348+ close(errCh)
349+
350+ var errs []string
351+ for err := range errCh {
352+ errs = append(errs, err.Error())
353+ }
354+ if len(errs) > 0 {
355+ return fmt.Errorf("%s", strings.Join(errs, "; "))
356+ }
357+ return nil
358+}
359+
360+// startIdleReaper runs a background goroutine that kills idle servers.
361+// It has no shutdown mechanism by design: it lives exactly as long as
362+// the extension process, and zot reaps the whole process on exit.
363+func (b *bridge) startIdleReaper() {
364+ go func() {
365+ ticker := time.NewTicker(30 * time.Second)
366+ defer ticker.Stop()
367+ for range ticker.C {
368+ for _, srv := range b.servers {
369+ // Use per-server idle timeout
370+ idleTimeout := time.Duration(srv.config.IdleTimeout) * time.Second
371+ if srv.isIdle(idleTimeout) {
372+ b.logger.Printf("[%s] idle timeout, stopping", srv.name)
373+ srv.stop()
374+ }
375+ }
376+ }
377+ }()
378+}
379+
380+// stopAll shuts down all MCP servers.
381+func (b *bridge) stopAll() {
382+ for _, srv := range b.servers {
383+ srv.stop()
384+ }
385+}
386+
387+// serverStatus returns compact status info for all servers in stable order.
388+func (b *bridge) serverStatus() []string {
389+ names := make([]string, 0, len(b.servers))
390+ for name := range b.servers {
391+ names = append(names, name)
392+ }
393+ sort.Strings(names)
394+
395+ lines := make([]string, 0, len(names))
396+ for _, name := range names {
397+ lines = append(lines, b.servers[name].status())
398+ }
399+ return lines
400+}
401+
402+func (b *bridge) notifyLevel() string {
403+ if len(b.servers) == 0 {
404+ return "info"
405+ }
406+ ready := 0
407+ errored := 0
408+ for _, srv := range b.servers {
409+ srv.mu.Lock()
410+ state := srv.state
411+ srv.mu.Unlock()
412+ switch state {
413+ case stateReady:
414+ ready++
415+ case stateError:
416+ errored++
417+ }
418+ }
419+ if errored > 0 && ready == 0 {
420+ return "error"
421+ }
422+ if errored > 0 {
423+ return "warn"
424+ }
425+ return "success"
426+}
427+
428+// startServer manually starts a specific server.
429+func (b *bridge) startServer(name string) error {
430+ srv, ok := b.servers[name]
431+ if !ok {
432+ return fmt.Errorf("unknown server: %s", name)
433+ }
434+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
435+ defer cancel()
436+ return srv.start(ctx)
437+}
438+
439+// stopServer manually stops a specific server.
440+func (b *bridge) stopServer(name string) error {
441+ srv, ok := b.servers[name]
442+ if !ok {
443+ return fmt.Errorf("unknown server: %s", name)
444+ }
445+ srv.stop()
446+ return nil
447+}
A examples/extensions/mcp-bridge/bridge_test.go +90 -0
@@ -0,0 +1,90 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log"
9+ "strings"
10+ "testing"
11+)
12+
13+func testBridge() *bridge {
14+ return &bridge{
15+ servers: map[string]*managedServer{},
16+ mapping: map[string]toolMapping{},
17+ logger: log.New(io.Discard, "", 0),
18+ }
19+}
20+
21+func TestHandleToolCallUnknownToolIsFriendlyError(t *testing.T) {
22+ b := testBridge()
23+ res := b.handleToolCall("mcp__ghost__tool", nil)
24+ if !res.IsError {
25+ t.Fatal("expected error result")
26+ }
27+}
28+
29+func TestHandleToolCallStartFailureIsFriendlyError(t *testing.T) {
30+ b := testBridge()
31+ b.servers["bogus"] = newManagedServer("bogus",
32+ ServerConfig{Transport: "no-such-transport", ConnectTimeout: 1, RequestTimeout: 1},
33+ log.New(io.Discard, "", 0))
34+ b.mapping["mcp__bogus__t"] = toolMapping{serverName: "bogus", mcpTool: "t"}
35+
36+ res := b.handleToolCall("mcp__bogus__t", nil)
37+ if !res.IsError {
38+ t.Fatal("expected error result for failed server start")
39+ }
40+}
41+
42+func TestClassifyTimeoutUsesErrorsIs(t *testing.T) {
43+ wrapped := errors.Join(errors.New("call failed"), context.DeadlineExceeded)
44+ if !errors.Is(wrapped, context.DeadlineExceeded) {
45+ t.Fatal("sanity: errors.Is must see through the wrap")
46+ }
47+ // The user-facing message for a deadline error must mention the
48+ // configured timeout even when the error text never says "timeout".
49+ msg := timeoutErrorMessage("slow", 42)
50+ if !strings.Contains(msg, "42") || !strings.Contains(msg, "slow") {
51+ t.Fatalf("timeout message missing details: %q", msg)
52+ }
53+}
54+
55+func TestSanitizeName(t *testing.T) {
56+ cases := []struct{ in, want string }{
57+ {"filesystem", "filesystem"},
58+ {"my-tool", "my_tool"},
59+ {"my.tool", "my_tool"},
60+ {"3d-render", "t_3d_render"},
61+ {"", "unnamed"},
62+ }
63+ for _, c := range cases {
64+ if got := sanitizeName(c.in); got != c.want {
65+ t.Errorf("sanitizeName(%q) = %q, want %q", c.in, got, c.want)
66+ }
67+ }
68+}
69+
70+func TestToolName(t *testing.T) {
71+ if got, want := toolName("fs-server", "read.file"), "mcp__fs_server__read_file"; got != want {
72+ t.Fatalf("toolName = %q, want %q", got, want)
73+ }
74+}
75+
76+func TestRegisterCachedToolLogsCollision(t *testing.T) {
77+ var buf bytes.Buffer
78+ b := testBridge()
79+ b.logger = log.New(&buf, "", 0)
80+ // nil Extension: registerCachedTool must not reach b.e.Tool for
81+ // the colliding (skipped) registration, and the first registration
82+ // needs a real Extension — so pre-seed the mapping instead.
83+ b.mapping["mcp__srv__my_tool"] = toolMapping{serverName: "srv", mcpTool: "my-tool"}
84+
85+ b.registerCachedTool("srv", cachedTool{Name: "my_tool", Schema: []byte(`{}`)})
86+
87+ if !strings.Contains(buf.String(), "collision") {
88+ t.Fatalf("expected collision log, got: %q", buf.String())
89+ }
90+}
@@ -0,0 +1,90 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "log"
9+ "strings"
10+ "testing"
11+)
12+
13+func testBridge() *bridge {
14+ return &bridge{
15+ servers: map[string]*managedServer{},
16+ mapping: map[string]toolMapping{},
17+ logger: log.New(io.Discard, "", 0),
18+ }
19+}
20+
21+func TestHandleToolCallUnknownToolIsFriendlyError(t *testing.T) {
22+ b := testBridge()
23+ res := b.handleToolCall("mcp__ghost__tool", nil)
24+ if !res.IsError {
25+ t.Fatal("expected error result")
26+ }
27+}
28+
29+func TestHandleToolCallStartFailureIsFriendlyError(t *testing.T) {
30+ b := testBridge()
31+ b.servers["bogus"] = newManagedServer("bogus",
32+ ServerConfig{Transport: "no-such-transport", ConnectTimeout: 1, RequestTimeout: 1},
33+ log.New(io.Discard, "", 0))
34+ b.mapping["mcp__bogus__t"] = toolMapping{serverName: "bogus", mcpTool: "t"}
35+
36+ res := b.handleToolCall("mcp__bogus__t", nil)
37+ if !res.IsError {
38+ t.Fatal("expected error result for failed server start")
39+ }
40+}
41+
42+func TestClassifyTimeoutUsesErrorsIs(t *testing.T) {
43+ wrapped := errors.Join(errors.New("call failed"), context.DeadlineExceeded)
44+ if !errors.Is(wrapped, context.DeadlineExceeded) {
45+ t.Fatal("sanity: errors.Is must see through the wrap")
46+ }
47+ // The user-facing message for a deadline error must mention the
48+ // configured timeout even when the error text never says "timeout".
49+ msg := timeoutErrorMessage("slow", 42)
50+ if !strings.Contains(msg, "42") || !strings.Contains(msg, "slow") {
51+ t.Fatalf("timeout message missing details: %q", msg)
52+ }
53+}
54+
55+func TestSanitizeName(t *testing.T) {
56+ cases := []struct{ in, want string }{
57+ {"filesystem", "filesystem"},
58+ {"my-tool", "my_tool"},
59+ {"my.tool", "my_tool"},
60+ {"3d-render", "t_3d_render"},
61+ {"", "unnamed"},
62+ }
63+ for _, c := range cases {
64+ if got := sanitizeName(c.in); got != c.want {
65+ t.Errorf("sanitizeName(%q) = %q, want %q", c.in, got, c.want)
66+ }
67+ }
68+}
69+
70+func TestToolName(t *testing.T) {
71+ if got, want := toolName("fs-server", "read.file"), "mcp__fs_server__read_file"; got != want {
72+ t.Fatalf("toolName = %q, want %q", got, want)
73+ }
74+}
75+
76+func TestRegisterCachedToolLogsCollision(t *testing.T) {
77+ var buf bytes.Buffer
78+ b := testBridge()
79+ b.logger = log.New(&buf, "", 0)
80+ // nil Extension: registerCachedTool must not reach b.e.Tool for
81+ // the colliding (skipped) registration, and the first registration
82+ // needs a real Extension — so pre-seed the mapping instead.
83+ b.mapping["mcp__srv__my_tool"] = toolMapping{serverName: "srv", mcpTool: "my-tool"}
84+
85+ b.registerCachedTool("srv", cachedTool{Name: "my_tool", Schema: []byte(`{}`)})
86+
87+ if !strings.Contains(buf.String(), "collision") {
88+ t.Fatalf("expected collision log, got: %q", buf.String())
89+ }
90+}
A examples/extensions/mcp-bridge/cache.go +98 -0
@@ -0,0 +1,98 @@
1+package main
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io/fs"
10+ "os"
11+ "path/filepath"
12+
13+ "github.com/mark3labs/mcp-go/mcp"
14+)
15+
16+const toolCacheVersion = 1
17+
18+type cachedTool struct {
19+ Name string `json:"name"`
20+ Description string `json:"description"`
21+ Schema json.RawMessage `json:"schema"`
22+}
23+
24+type cachedServer struct {
25+ Fingerprint string `json:"fingerprint"`
26+ Tools []cachedTool `json:"tools"`
27+}
28+
29+type toolCache struct {
30+ Version int `json:"version"`
31+ Servers map[string]cachedServer `json:"servers"`
32+}
33+
34+func toolCachePath() string {
35+ return filepath.Join(zotHome(), "mcp-tools-cache.json")
36+}
37+
38+func serverFingerprint(cfg ServerConfig) string {
39+ data, _ := json.Marshal(cfg)
40+ sum := sha256.Sum256(data)
41+ return hex.EncodeToString(sum[:])
42+}
43+
44+func readToolCache(path string) (toolCache, error) {
45+ cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
46+ data, err := os.ReadFile(path)
47+ if err != nil {
48+ if errors.Is(err, fs.ErrNotExist) {
49+ return cache, nil
50+ }
51+ return cache, err
52+ }
53+ if err := json.Unmarshal(data, &cache); err != nil {
54+ return cache, fmt.Errorf("parse %s: %w", path, err)
55+ }
56+ if cache.Version != toolCacheVersion || cache.Servers == nil {
57+ return toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}, nil
58+ }
59+ return cache, nil
60+}
61+
62+func toolCachesEqual(a, b toolCache) bool {
63+ aj, err := json.Marshal(a)
64+ if err != nil {
65+ return false
66+ }
67+ bj, err := json.Marshal(b)
68+ if err != nil {
69+ return false
70+ }
71+ return string(aj) == string(bj)
72+}
73+
74+func writeToolCache(path string, cache toolCache) error {
75+ if cache.Servers == nil {
76+ cache.Servers = map[string]cachedServer{}
77+ }
78+ cache.Version = toolCacheVersion
79+ data, err := json.MarshalIndent(cache, "", " ")
80+ if err != nil {
81+ return err
82+ }
83+ data = append(data, '\n')
84+ return writeFileAtomic(path, data, 0o600)
85+}
86+
87+func cachedToolFromMCP(serverName string, tool mcp.Tool) (cachedTool, error) {
88+ schema := mcpToolSchema(tool)
89+ schemaJSON, err := json.Marshal(schema)
90+ if err != nil {
91+ return cachedTool{}, err
92+ }
93+ return cachedTool{
94+ Name: tool.Name,
95+ Description: mcpToolDescription(serverName, tool),
96+ Schema: json.RawMessage(schemaJSON),
97+ }, nil
98+}
@@ -0,0 +1,98 @@
1+package main
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io/fs"
10+ "os"
11+ "path/filepath"
12+
13+ "github.com/mark3labs/mcp-go/mcp"
14+)
15+
16+const toolCacheVersion = 1
17+
18+type cachedTool struct {
19+ Name string `json:"name"`
20+ Description string `json:"description"`
21+ Schema json.RawMessage `json:"schema"`
22+}
23+
24+type cachedServer struct {
25+ Fingerprint string `json:"fingerprint"`
26+ Tools []cachedTool `json:"tools"`
27+}
28+
29+type toolCache struct {
30+ Version int `json:"version"`
31+ Servers map[string]cachedServer `json:"servers"`
32+}
33+
34+func toolCachePath() string {
35+ return filepath.Join(zotHome(), "mcp-tools-cache.json")
36+}
37+
38+func serverFingerprint(cfg ServerConfig) string {
39+ data, _ := json.Marshal(cfg)
40+ sum := sha256.Sum256(data)
41+ return hex.EncodeToString(sum[:])
42+}
43+
44+func readToolCache(path string) (toolCache, error) {
45+ cache := toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}
46+ data, err := os.ReadFile(path)
47+ if err != nil {
48+ if errors.Is(err, fs.ErrNotExist) {
49+ return cache, nil
50+ }
51+ return cache, err
52+ }
53+ if err := json.Unmarshal(data, &cache); err != nil {
54+ return cache, fmt.Errorf("parse %s: %w", path, err)
55+ }
56+ if cache.Version != toolCacheVersion || cache.Servers == nil {
57+ return toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{}}, nil
58+ }
59+ return cache, nil
60+}
61+
62+func toolCachesEqual(a, b toolCache) bool {
63+ aj, err := json.Marshal(a)
64+ if err != nil {
65+ return false
66+ }
67+ bj, err := json.Marshal(b)
68+ if err != nil {
69+ return false
70+ }
71+ return string(aj) == string(bj)
72+}
73+
74+func writeToolCache(path string, cache toolCache) error {
75+ if cache.Servers == nil {
76+ cache.Servers = map[string]cachedServer{}
77+ }
78+ cache.Version = toolCacheVersion
79+ data, err := json.MarshalIndent(cache, "", " ")
80+ if err != nil {
81+ return err
82+ }
83+ data = append(data, '\n')
84+ return writeFileAtomic(path, data, 0o600)
85+}
86+
87+func cachedToolFromMCP(serverName string, tool mcp.Tool) (cachedTool, error) {
88+ schema := mcpToolSchema(tool)
89+ schemaJSON, err := json.Marshal(schema)
90+ if err != nil {
91+ return cachedTool{}, err
92+ }
93+ return cachedTool{
94+ Name: tool.Name,
95+ Description: mcpToolDescription(serverName, tool),
96+ Schema: json.RawMessage(schemaJSON),
97+ }, nil
98+}
A examples/extensions/mcp-bridge/config.go +114 -0
@@ -0,0 +1,114 @@
1+// config.go — MCP server configuration loading.
2+//
3+// Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
4+// from two locations:
5+//
6+// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)
7+// 2. Project: .zot/mcp.json (in the current working directory)
8+//
9+// Project config overrides global config per-server (shallow merge).
10+package main
11+
12+import (
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io/fs"
17+ "os"
18+ "path/filepath"
19+ "runtime"
20+)
21+
22+// ServerConfig describes one MCP server entry.
23+type ServerConfig struct {
24+ // Stdio transport fields
25+ Command string `json:"command,omitempty"` // executable to spawn
26+ Args []string `json:"args,omitempty"` // arguments
27+ Env map[string]string `json:"env,omitempty"` // extra env vars
28+
29+ // HTTP transport fields
30+ Transport string `json:"transport,omitempty"` // "stdio" (default) | "streamable-http" | "sse"
31+ URL string `json:"url,omitempty"` // server URL for HTTP transports
32+ Headers map[string]string `json:"headers,omitempty"` // custom HTTP headers
33+
34+ // Timeouts (in seconds)
35+ ConnectTimeout int `json:"connectTimeout,omitempty"` // connection timeout (default: 30)
36+ RequestTimeout int `json:"requestTimeout,omitempty"` // per-request timeout (default: 60)
37+ IdleTimeout int `json:"idleTimeout,omitempty"` // idle timeout before stopping (default: 300)
38+}
39+
40+// Config is the top-level MCP configuration.
41+type Config struct {
42+ MCPServers map[string]ServerConfig `json:"mcpServers"`
43+}
44+
45+// zotHome returns the zot state directory.
46+func zotHome() string {
47+ if h := os.Getenv("ZOT_HOME"); h != "" {
48+ return h
49+ }
50+ switch runtime.GOOS {
51+ case "darwin":
52+ home, _ := os.UserHomeDir()
53+ return filepath.Join(home, "Library", "Application Support", "zot")
54+ case "windows":
55+ return filepath.Join(os.Getenv("APPDATA"), "zot")
56+ default: // linux, freebsd, etc.
57+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58+ return filepath.Join(xdg, "zot")
59+ }
60+ home, _ := os.UserHomeDir()
61+ return filepath.Join(home, ".local", "state", "zot")
62+ }
63+}
64+
65+// loadConfig reads and merges global + project MCP configs.
66+// cwd is the current working directory (for project config lookup).
67+func loadConfig(cwd string) (Config, error) {
68+ cfg := Config{MCPServers: make(map[string]ServerConfig)}
69+
70+ // 1. Global config
71+ globalPath := filepath.Join(zotHome(), "mcp.json")
72+ if err := mergeConfig(&cfg, globalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
73+ return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
74+ }
75+
76+ // 2. Project config (overrides global per-server)
77+ if cwd != "" {
78+ projectPath := filepath.Join(cwd, ".zot", "mcp.json")
79+ if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
80+ return cfg, fmt.Errorf("project config %s: %w", projectPath, err)
81+ }
82+ }
83+
84+ return cfg, nil
85+}
86+
87+// mergeConfig reads a JSON config file and merges its servers into cfg.
88+func mergeConfig(cfg *Config, path string) error {
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ return err
92+ }
93+ var file Config
94+ if err := json.Unmarshal(data, &file); err != nil {
95+ return fmt.Errorf("parse %s: %w", path, err)
96+ }
97+ for name, srv := range file.MCPServers {
98+ // Apply defaults
99+ if srv.Transport == "" {
100+ srv.Transport = "stdio"
101+ }
102+ if srv.ConnectTimeout == 0 {
103+ srv.ConnectTimeout = 30
104+ }
105+ if srv.RequestTimeout == 0 {
106+ srv.RequestTimeout = 60
107+ }
108+ if srv.IdleTimeout == 0 {
109+ srv.IdleTimeout = 300
110+ }
111+ cfg.MCPServers[name] = srv
112+ }
113+ return nil
114+}
@@ -0,0 +1,114 @@
1+// config.go — MCP server configuration loading.
2+//
3+// Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
4+// from two locations:
5+//
6+// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)
7+// 2. Project: .zot/mcp.json (in the current working directory)
8+//
9+// Project config overrides global config per-server (shallow merge).
10+package main
11+
12+import (
13+ "encoding/json"
14+ "errors"
15+ "fmt"
16+ "io/fs"
17+ "os"
18+ "path/filepath"
19+ "runtime"
20+)
21+
22+// ServerConfig describes one MCP server entry.
23+type ServerConfig struct {
24+ // Stdio transport fields
25+ Command string `json:"command,omitempty"` // executable to spawn
26+ Args []string `json:"args,omitempty"` // arguments
27+ Env map[string]string `json:"env,omitempty"` // extra env vars
28+
29+ // HTTP transport fields
30+ Transport string `json:"transport,omitempty"` // "stdio" (default) | "streamable-http" | "sse"
31+ URL string `json:"url,omitempty"` // server URL for HTTP transports
32+ Headers map[string]string `json:"headers,omitempty"` // custom HTTP headers
33+
34+ // Timeouts (in seconds)
35+ ConnectTimeout int `json:"connectTimeout,omitempty"` // connection timeout (default: 30)
36+ RequestTimeout int `json:"requestTimeout,omitempty"` // per-request timeout (default: 60)
37+ IdleTimeout int `json:"idleTimeout,omitempty"` // idle timeout before stopping (default: 300)
38+}
39+
40+// Config is the top-level MCP configuration.
41+type Config struct {
42+ MCPServers map[string]ServerConfig `json:"mcpServers"`
43+}
44+
45+// zotHome returns the zot state directory.
46+func zotHome() string {
47+ if h := os.Getenv("ZOT_HOME"); h != "" {
48+ return h
49+ }
50+ switch runtime.GOOS {
51+ case "darwin":
52+ home, _ := os.UserHomeDir()
53+ return filepath.Join(home, "Library", "Application Support", "zot")
54+ case "windows":
55+ return filepath.Join(os.Getenv("APPDATA"), "zot")
56+ default: // linux, freebsd, etc.
57+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58+ return filepath.Join(xdg, "zot")
59+ }
60+ home, _ := os.UserHomeDir()
61+ return filepath.Join(home, ".local", "state", "zot")
62+ }
63+}
64+
65+// loadConfig reads and merges global + project MCP configs.
66+// cwd is the current working directory (for project config lookup).
67+func loadConfig(cwd string) (Config, error) {
68+ cfg := Config{MCPServers: make(map[string]ServerConfig)}
69+
70+ // 1. Global config
71+ globalPath := filepath.Join(zotHome(), "mcp.json")
72+ if err := mergeConfig(&cfg, globalPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
73+ return cfg, fmt.Errorf("global config %s: %w", globalPath, err)
74+ }
75+
76+ // 2. Project config (overrides global per-server)
77+ if cwd != "" {
78+ projectPath := filepath.Join(cwd, ".zot", "mcp.json")
79+ if err := mergeConfig(&cfg, projectPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
80+ return cfg, fmt.Errorf("project config %s: %w", projectPath, err)
81+ }
82+ }
83+
84+ return cfg, nil
85+}
86+
87+// mergeConfig reads a JSON config file and merges its servers into cfg.
88+func mergeConfig(cfg *Config, path string) error {
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ return err
92+ }
93+ var file Config
94+ if err := json.Unmarshal(data, &file); err != nil {
95+ return fmt.Errorf("parse %s: %w", path, err)
96+ }
97+ for name, srv := range file.MCPServers {
98+ // Apply defaults
99+ if srv.Transport == "" {
100+ srv.Transport = "stdio"
101+ }
102+ if srv.ConnectTimeout == 0 {
103+ srv.ConnectTimeout = 30
104+ }
105+ if srv.RequestTimeout == 0 {
106+ srv.RequestTimeout = 60
107+ }
108+ if srv.IdleTimeout == 0 {
109+ srv.IdleTimeout = 300
110+ }
111+ cfg.MCPServers[name] = srv
112+ }
113+ return nil
114+}
A examples/extensions/mcp-bridge/config_test.go +68 -0
@@ -0,0 +1,68 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func writeJSON(t *testing.T, path, content string) {
10+ t.Helper()
11+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
12+ t.Fatal(err)
13+ }
14+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+}
18+
19+func TestLoadConfigProjectOverridesGlobal(t *testing.T) {
20+ home := t.TempDir()
21+ proj := t.TempDir()
22+ t.Setenv("ZOT_HOME", home)
23+
24+ writeJSON(t, filepath.Join(home, "mcp.json"), `{
25+ "mcpServers": {
26+ "shared": {"command": "global-cmd"},
27+ "global-only": {"command": "g"}
28+ }
29+ }`)
30+ writeJSON(t, filepath.Join(proj, ".zot", "mcp.json"), `{
31+ "mcpServers": {
32+ "shared": {"command": "project-cmd"}
33+ }
34+ }`)
35+
36+ cfg, err := loadConfig(proj)
37+ if err != nil {
38+ t.Fatalf("loadConfig: %v", err)
39+ }
40+ if got := cfg.MCPServers["shared"].Command; got != "project-cmd" {
41+ t.Fatalf("project must override global: shared.command = %q", got)
42+ }
43+ if _, ok := cfg.MCPServers["global-only"]; !ok {
44+ t.Fatal("global-only server lost in merge")
45+ }
46+ // Defaults applied during merge:
47+ if got := cfg.MCPServers["shared"].RequestTimeout; got != 60 {
48+ t.Fatalf("default requestTimeout = %d, want 60", got)
49+ }
50+}
51+
52+func TestLoadConfigMissingFilesIsNotAnError(t *testing.T) {
53+ t.Setenv("ZOT_HOME", t.TempDir())
54+ cfg, err := loadConfig(t.TempDir())
55+ if err != nil {
56+ t.Fatalf("loadConfig with no files: %v", err)
57+ }
58+ if len(cfg.MCPServers) != 0 {
59+ t.Fatalf("expected empty config, got %+v", cfg.MCPServers)
60+ }
61+}
62+
63+func TestHandleSetupProjectRequiresCwd(t *testing.T) {
64+ t.Setenv("ZOT_HOME", t.TempDir())
65+ if _, err := handleSetup([]string{"add", "grep", "--project"}, ""); err == nil {
66+ t.Fatal("expected error for --project with unknown working directory")
67+ }
68+}
@@ -0,0 +1,68 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func writeJSON(t *testing.T, path, content string) {
10+ t.Helper()
11+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
12+ t.Fatal(err)
13+ }
14+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
15+ t.Fatal(err)
16+ }
17+}
18+
19+func TestLoadConfigProjectOverridesGlobal(t *testing.T) {
20+ home := t.TempDir()
21+ proj := t.TempDir()
22+ t.Setenv("ZOT_HOME", home)
23+
24+ writeJSON(t, filepath.Join(home, "mcp.json"), `{
25+ "mcpServers": {
26+ "shared": {"command": "global-cmd"},
27+ "global-only": {"command": "g"}
28+ }
29+ }`)
30+ writeJSON(t, filepath.Join(proj, ".zot", "mcp.json"), `{
31+ "mcpServers": {
32+ "shared": {"command": "project-cmd"}
33+ }
34+ }`)
35+
36+ cfg, err := loadConfig(proj)
37+ if err != nil {
38+ t.Fatalf("loadConfig: %v", err)
39+ }
40+ if got := cfg.MCPServers["shared"].Command; got != "project-cmd" {
41+ t.Fatalf("project must override global: shared.command = %q", got)
42+ }
43+ if _, ok := cfg.MCPServers["global-only"]; !ok {
44+ t.Fatal("global-only server lost in merge")
45+ }
46+ // Defaults applied during merge:
47+ if got := cfg.MCPServers["shared"].RequestTimeout; got != 60 {
48+ t.Fatalf("default requestTimeout = %d, want 60", got)
49+ }
50+}
51+
52+func TestLoadConfigMissingFilesIsNotAnError(t *testing.T) {
53+ t.Setenv("ZOT_HOME", t.TempDir())
54+ cfg, err := loadConfig(t.TempDir())
55+ if err != nil {
56+ t.Fatalf("loadConfig with no files: %v", err)
57+ }
58+ if len(cfg.MCPServers) != 0 {
59+ t.Fatalf("expected empty config, got %+v", cfg.MCPServers)
60+ }
61+}
62+
63+func TestHandleSetupProjectRequiresCwd(t *testing.T) {
64+ t.Setenv("ZOT_HOME", t.TempDir())
65+ if _, err := handleSetup([]string{"add", "grep", "--project"}, ""); err == nil {
66+ t.Fatal("expected error for --project with unknown working directory")
67+ }
68+}
A examples/extensions/mcp-bridge/extension.json +8 -0
@@ -0,0 +1,8 @@
1+{
2+ "name": "mcp",
3+ "version": "1.0.0",
4+ "exec": "./mcp-bridge",
5+ "language": "go",
6+ "description": "Connect zot to MCP servers - tools are registered automatically",
7+ "enabled": true
8+}
@@ -0,0 +1,8 @@
1+{
2+ "name": "mcp",
3+ "version": "1.0.0",
4+ "exec": "./mcp-bridge",
5+ "language": "go",
6+ "description": "Connect zot to MCP servers - tools are registered automatically",
7+ "enabled": true
8+}
A examples/extensions/mcp-bridge/fsutil.go +35 -0
@@ -0,0 +1,35 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+)
7+
8+// writeFileAtomic writes data to path via a temp file + rename in the
9+// same directory, so readers never observe a partially written file
10+// and a crash mid-write cannot corrupt the target.
11+func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
12+ dir := filepath.Dir(path)
13+ if err := os.MkdirAll(dir, 0o755); err != nil {
14+ return err
15+ }
16+ tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
17+ if err != nil {
18+ return err
19+ }
20+ tmpName := tmp.Name()
21+ defer os.Remove(tmpName) // no-op after successful rename
22+
23+ if err := tmp.Chmod(perm); err != nil {
24+ tmp.Close()
25+ return err
26+ }
27+ if _, err := tmp.Write(data); err != nil {
28+ tmp.Close()
29+ return err
30+ }
31+ if err := tmp.Close(); err != nil {
32+ return err
33+ }
34+ return os.Rename(tmpName, path)
35+}
@@ -0,0 +1,35 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+)
7+
8+// writeFileAtomic writes data to path via a temp file + rename in the
9+// same directory, so readers never observe a partially written file
10+// and a crash mid-write cannot corrupt the target.
11+func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
12+ dir := filepath.Dir(path)
13+ if err := os.MkdirAll(dir, 0o755); err != nil {
14+ return err
15+ }
16+ tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
17+ if err != nil {
18+ return err
19+ }
20+ tmpName := tmp.Name()
21+ defer os.Remove(tmpName) // no-op after successful rename
22+
23+ if err := tmp.Chmod(perm); err != nil {
24+ tmp.Close()
25+ return err
26+ }
27+ if _, err := tmp.Write(data); err != nil {
28+ tmp.Close()
29+ return err
30+ }
31+ if err := tmp.Close(); err != nil {
32+ return err
33+ }
34+ return os.Rename(tmpName, path)
35+}
A examples/extensions/mcp-bridge/fsutil_test.go +95 -0
@@ -0,0 +1,95 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestWriteFileAtomicWritesContentAndPerms(t *testing.T) {
10+ path := filepath.Join(t.TempDir(), "sub", "file.json")
11+ if err := writeFileAtomic(path, []byte("{}\n"), 0o600); err != nil {
12+ t.Fatalf("writeFileAtomic: %v", err)
13+ }
14+ data, err := os.ReadFile(path)
15+ if err != nil {
16+ t.Fatalf("read back: %v", err)
17+ }
18+ if string(data) != "{}\n" {
19+ t.Fatalf("content = %q, want %q", data, "{}\n")
20+ }
21+ info, err := os.Stat(path)
22+ if err != nil {
23+ t.Fatalf("stat: %v", err)
24+ }
25+ if got := info.Mode().Perm(); got != 0o600 {
26+ t.Fatalf("perm = %o, want 600", got)
27+ }
28+}
29+
30+func TestWriteFileAtomicLeavesNoTempFileBehind(t *testing.T) {
31+ dir := t.TempDir()
32+ path := filepath.Join(dir, "file.json")
33+ if err := writeFileAtomic(path, []byte("x"), 0o600); err != nil {
34+ t.Fatalf("writeFileAtomic: %v", err)
35+ }
36+ entries, err := os.ReadDir(dir)
37+ if err != nil {
38+ t.Fatalf("readdir: %v", err)
39+ }
40+ if len(entries) != 1 || entries[0].Name() != "file.json" {
41+ t.Fatalf("directory not clean: %v", entries)
42+ }
43+}
44+
45+func TestToolCacheRoundTrip(t *testing.T) {
46+ path := filepath.Join(t.TempDir(), "mcp-tools-cache.json")
47+ in := toolCache{
48+ Version: toolCacheVersion,
49+ Servers: map[string]cachedServer{
50+ "srv": {Fingerprint: "abc", Tools: []cachedTool{
51+ {Name: "t1", Description: "d", Schema: []byte(`{"type":"object"}`)},
52+ }},
53+ },
54+ }
55+ if err := writeToolCache(path, in); err != nil {
56+ t.Fatalf("writeToolCache: %v", err)
57+ }
58+ out, err := readToolCache(path)
59+ if err != nil {
60+ t.Fatalf("readToolCache: %v", err)
61+ }
62+ if !toolCachesEqual(in, out) {
63+ t.Fatalf("round trip mismatch:\nin: %+v\nout: %+v", in, out)
64+ }
65+}
66+
67+func TestToolCacheVersionMismatchInvalidates(t *testing.T) {
68+ path := filepath.Join(t.TempDir(), "mcp-tools-cache.json")
69+ if err := os.WriteFile(path, []byte(`{"version": 999, "servers": {"srv": {}}}`), 0o600); err != nil {
70+ t.Fatal(err)
71+ }
72+ out, err := readToolCache(path)
73+ if err != nil {
74+ t.Fatalf("readToolCache: %v", err)
75+ }
76+ if len(out.Servers) != 0 || out.Version != toolCacheVersion {
77+ t.Fatalf("stale-version cache must be discarded, got %+v", out)
78+ }
79+}
80+
81+func TestConfigFileWrittenOwnerOnly(t *testing.T) {
82+ tmp := t.TempDir()
83+ t.Setenv("ZOT_HOME", tmp)
84+
85+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err != nil {
86+ t.Fatalf("handleSetup: %v", err)
87+ }
88+ info, err := os.Stat(filepath.Join(tmp, "mcp.json"))
89+ if err != nil {
90+ t.Fatalf("stat mcp.json: %v", err)
91+ }
92+ if got := info.Mode().Perm(); got != 0o600 {
93+ t.Fatalf("mcp.json perm = %o, want 600 (may contain auth headers)", got)
94+ }
95+}
@@ -0,0 +1,95 @@
1+package main
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestWriteFileAtomicWritesContentAndPerms(t *testing.T) {
10+ path := filepath.Join(t.TempDir(), "sub", "file.json")
11+ if err := writeFileAtomic(path, []byte("{}\n"), 0o600); err != nil {
12+ t.Fatalf("writeFileAtomic: %v", err)
13+ }
14+ data, err := os.ReadFile(path)
15+ if err != nil {
16+ t.Fatalf("read back: %v", err)
17+ }
18+ if string(data) != "{}\n" {
19+ t.Fatalf("content = %q, want %q", data, "{}\n")
20+ }
21+ info, err := os.Stat(path)
22+ if err != nil {
23+ t.Fatalf("stat: %v", err)
24+ }
25+ if got := info.Mode().Perm(); got != 0o600 {
26+ t.Fatalf("perm = %o, want 600", got)
27+ }
28+}
29+
30+func TestWriteFileAtomicLeavesNoTempFileBehind(t *testing.T) {
31+ dir := t.TempDir()
32+ path := filepath.Join(dir, "file.json")
33+ if err := writeFileAtomic(path, []byte("x"), 0o600); err != nil {
34+ t.Fatalf("writeFileAtomic: %v", err)
35+ }
36+ entries, err := os.ReadDir(dir)
37+ if err != nil {
38+ t.Fatalf("readdir: %v", err)
39+ }
40+ if len(entries) != 1 || entries[0].Name() != "file.json" {
41+ t.Fatalf("directory not clean: %v", entries)
42+ }
43+}
44+
45+func TestToolCacheRoundTrip(t *testing.T) {
46+ path := filepath.Join(t.TempDir(), "mcp-tools-cache.json")
47+ in := toolCache{
48+ Version: toolCacheVersion,
49+ Servers: map[string]cachedServer{
50+ "srv": {Fingerprint: "abc", Tools: []cachedTool{
51+ {Name: "t1", Description: "d", Schema: []byte(`{"type":"object"}`)},
52+ }},
53+ },
54+ }
55+ if err := writeToolCache(path, in); err != nil {
56+ t.Fatalf("writeToolCache: %v", err)
57+ }
58+ out, err := readToolCache(path)
59+ if err != nil {
60+ t.Fatalf("readToolCache: %v", err)
61+ }
62+ if !toolCachesEqual(in, out) {
63+ t.Fatalf("round trip mismatch:\nin: %+v\nout: %+v", in, out)
64+ }
65+}
66+
67+func TestToolCacheVersionMismatchInvalidates(t *testing.T) {
68+ path := filepath.Join(t.TempDir(), "mcp-tools-cache.json")
69+ if err := os.WriteFile(path, []byte(`{"version": 999, "servers": {"srv": {}}}`), 0o600); err != nil {
70+ t.Fatal(err)
71+ }
72+ out, err := readToolCache(path)
73+ if err != nil {
74+ t.Fatalf("readToolCache: %v", err)
75+ }
76+ if len(out.Servers) != 0 || out.Version != toolCacheVersion {
77+ t.Fatalf("stale-version cache must be discarded, got %+v", out)
78+ }
79+}
80+
81+func TestConfigFileWrittenOwnerOnly(t *testing.T) {
82+ tmp := t.TempDir()
83+ t.Setenv("ZOT_HOME", tmp)
84+
85+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err != nil {
86+ t.Fatalf("handleSetup: %v", err)
87+ }
88+ info, err := os.Stat(filepath.Join(tmp, "mcp.json"))
89+ if err != nil {
90+ t.Fatalf("stat mcp.json: %v", err)
91+ }
92+ if got := info.Mode().Perm(); got != 0o600 {
93+ t.Fatalf("mcp.json perm = %o, want 600 (may contain auth headers)", got)
94+ }
95+}
A examples/extensions/mcp-bridge/go.mod +19 -0
@@ -0,0 +1,19 @@
1+module github.com/patriceckhart/zot/examples/extensions/mcp-bridge
2+
3+go 1.25.5
4+
5+replace github.com/patriceckhart/zot => ../../..
6+
7+require (
8+ github.com/mark3labs/mcp-go v0.55.1
9+ github.com/patriceckhart/zot v0.2.47
10+)
11+
12+require (
13+ github.com/google/jsonschema-go v0.4.2 // indirect
14+ github.com/google/uuid v1.6.0 // indirect
15+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
16+ github.com/spf13/cast v1.7.1 // indirect
17+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
18+ golang.org/x/text v0.14.0 // indirect
19+)
@@ -0,0 +1,19 @@
1+module github.com/patriceckhart/zot/examples/extensions/mcp-bridge
2+
3+go 1.25.5
4+
5+replace github.com/patriceckhart/zot => ../../..
6+
7+require (
8+ github.com/mark3labs/mcp-go v0.55.1
9+ github.com/patriceckhart/zot v0.2.47
10+)
11+
12+require (
13+ github.com/google/jsonschema-go v0.4.2 // indirect
14+ github.com/google/uuid v1.6.0 // indirect
15+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
16+ github.com/spf13/cast v1.7.1 // indirect
17+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
18+ golang.org/x/text v0.14.0 // indirect
19+)
A examples/extensions/mcp-bridge/go.sum +34 -0
@@ -0,0 +1,34 @@
1+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
4+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
5+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
6+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
7+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
8+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
9+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
10+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
11+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
12+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
13+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17+github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA=
18+github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
19+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
20+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
21+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
22+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
23+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
24+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
25+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
26+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
27+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
28+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
29+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
30+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
31+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
32+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
33+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
34+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,34 @@
1+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
4+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
5+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
6+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
7+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
8+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
9+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
10+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
11+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
12+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
13+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17+github.com/mark3labs/mcp-go v0.55.1 h1:GLYqNm9qdMGPhCtK4g1t1y1vhAPfayOBuaibDi4mrSA=
18+github.com/mark3labs/mcp-go v0.55.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
19+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
20+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
21+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
22+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
23+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
24+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
25+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
26+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
27+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
28+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
29+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
30+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
31+golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
32+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
33+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
34+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A examples/extensions/mcp-bridge/main.go +347 -0
@@ -0,0 +1,347 @@
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 .
34+package main
35+
36+import (
37+ "context"
38+ "fmt"
39+ "log"
40+ "os"
41+ "strings"
42+ "time"
43+
44+ "github.com/patriceckhart/zot/packages/agent/ext"
45+)
46+
47+func 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+
106+func 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.
135+func 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+
189+func 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+
211+func 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+
224+func 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+
248+func 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+
268+func 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+
282+func 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+
304+func notifyBridgeStatus(e *ext.Extension, b *bridge) {
305+ if e == nil || b == nil {
306+ return
307+ }
308+ notifyText(e, b.notifyLevel(), formatStatusSummary(b))
309+}
310+
311+func 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+
328+func notifyText(e *ext.Extension, level, text string) {
329+ if e == nil {
330+ return
331+ }
332+ clearExtensionNotes()
333+ e.Notify(level, text)
334+}
335+
336+func clearExtensionNotes() {
337+ _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n")
338+}
339+
340+// formatStatusSummary builds a human-readable status line.
341+func 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+}
@@ -0,0 +1,347 @@
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 .
34+package main
35+
36+import (
37+ "context"
38+ "fmt"
39+ "log"
40+ "os"
41+ "strings"
42+ "time"
43+
44+ "github.com/patriceckhart/zot/packages/agent/ext"
45+)
46+
47+func 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+
106+func 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.
135+func 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+
189+func 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+
211+func 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+
224+func 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+
248+func 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+
268+func 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+
282+func 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+
304+func notifyBridgeStatus(e *ext.Extension, b *bridge) {
305+ if e == nil || b == nil {
306+ return
307+ }
308+ notifyText(e, b.notifyLevel(), formatStatusSummary(b))
309+}
310+
311+func 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+
328+func notifyText(e *ext.Extension, level, text string) {
329+ if e == nil {
330+ return
331+ }
332+ clearExtensionNotes()
333+ e.Notify(level, text)
334+}
335+
336+func clearExtensionNotes() {
337+ _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n")
338+}
339+
340+// formatStatusSummary builds a human-readable status line.
341+func 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+}
A examples/extensions/mcp-bridge/server.go +511 -0
@@ -0,0 +1,511 @@
1+// server.go — MCP server process lifecycle management.
2+//
3+// Each configured MCP server is wrapped in a managedServer that tracks:
4+// - Connection state (stopped / starting / ready)
5+// - Last-access time (for idle timeout)
6+// - Discovered tools
7+//
8+// Smart lazy: servers are spawned on startup to discover tools, then
9+// killed after an idle period. On the next tool call, they're respawned.
10+package main
11+
12+import (
13+ "bufio"
14+ "context"
15+ "encoding/json"
16+ "fmt"
17+ "io"
18+ "log"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "github.com/mark3labs/mcp-go/client"
24+ "github.com/mark3labs/mcp-go/client/transport"
25+ "github.com/mark3labs/mcp-go/mcp"
26+)
27+
28+// serverState tracks the lifecycle of one MCP server.
29+type serverState int
30+
31+const (
32+ stateStopped serverState = iota // not running
33+ stateStarting // spawning / initializing
34+ stateReady // connected, tools discovered
35+ stateError // failed to start
36+)
37+
38+func (s serverState) String() string {
39+ switch s {
40+ case stateStopped:
41+ return "stopped"
42+ case stateStarting:
43+ return "starting"
44+ case stateReady:
45+ return "ready"
46+ case stateError:
47+ return "error"
48+ default:
49+ return "unknown"
50+ }
51+}
52+
53+// managedServer wraps one MCP server with lifecycle management.
54+type managedServer struct {
55+ name string
56+ config ServerConfig
57+ logger *log.Logger
58+
59+ mu sync.Mutex
60+ state serverState
61+ client *client.Client
62+ tools []mcp.Tool
63+ lastUsed time.Time
64+ startErr error
65+ gen uint64 // bumped by stop(); a start attempt only commits if unchanged
66+}
67+
68+// newManagedServer creates a new server wrapper.
69+func newManagedServer(name string, cfg ServerConfig, logger *log.Logger) *managedServer {
70+ return &managedServer{
71+ name: name,
72+ config: cfg,
73+ logger: logger,
74+ state: stateStopped,
75+ lastUsed: time.Now(),
76+ }
77+}
78+
79+// start spawns the MCP server process and discovers its tools.
80+// Safe to call concurrently; a stop() issued while a start is in
81+// flight wins — the late result is discarded and its client closed.
82+func (s *managedServer) start(ctx context.Context) error {
83+ ctx, cancel := context.WithTimeout(ctx, time.Duration(s.config.ConnectTimeout)*time.Second)
84+ defer cancel()
85+
86+ s.mu.Lock()
87+ if s.state == stateReady {
88+ s.lastUsed = time.Now()
89+ s.mu.Unlock()
90+ return nil
91+ }
92+ if s.state == stateStarting {
93+ s.mu.Unlock()
94+ return s.waitForReady(ctx)
95+ }
96+ s.state = stateStarting
97+ s.startErr = nil
98+ gen := s.gen
99+ s.mu.Unlock()
100+
101+ c, tools, err := s.doStart(ctx)
102+ return s.finishStart(gen, c, tools, err)
103+}
104+
105+// finishStart commits the outcome of a start attempt, unless stop()
106+// bumped the generation in the meantime.
107+func (s *managedServer) finishStart(gen uint64, c *client.Client, tools []mcp.Tool, err error) error {
108+ s.mu.Lock()
109+ defer s.mu.Unlock()
110+
111+ if s.gen != gen {
112+ if c != nil {
113+ c.Close()
114+ }
115+ return fmt.Errorf("[%s] stopped while starting", s.name)
116+ }
117+ if err != nil {
118+ s.state = stateError
119+ s.startErr = err
120+ s.logger.Printf("[%s] start failed: %v", s.name, err)
121+ return err
122+ }
123+ s.client = c
124+ s.tools = tools
125+ s.state = stateReady
126+ s.lastUsed = time.Now()
127+ s.logger.Printf("[%s] ready with %d tools", s.name, len(s.tools))
128+ return nil
129+}
130+
131+// doStart performs the actual spawn + initialize + tools/list.
132+func (s *managedServer) doStart(ctx context.Context) (*client.Client, []mcp.Tool, error) {
133+ var c *client.Client
134+ var err error
135+
136+ switch s.config.Transport {
137+ case "stdio", "":
138+ c, err = s.startStdio()
139+ case "streamable-http":
140+ c, err = s.startStreamableHTTP(ctx)
141+ case "sse":
142+ c, err = s.startSSE(ctx)
143+ default:
144+ return nil, nil, fmt.Errorf("unknown transport: %s", s.config.Transport)
145+ }
146+
147+ if err != nil {
148+ return nil, nil, err
149+ }
150+
151+ // Initialize the MCP session
152+ _, err = c.Initialize(ctx, mcp.InitializeRequest{
153+ Params: mcp.InitializeParams{
154+ ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
155+ ClientInfo: mcp.Implementation{
156+ Name: "zot-mcp-bridge",
157+ Version: "1.0.0",
158+ },
159+ Capabilities: mcp.ClientCapabilities{},
160+ },
161+ })
162+ if err != nil {
163+ c.Close()
164+ return nil, nil, fmt.Errorf("initialize: %w", err)
165+ }
166+
167+ // Discover tools
168+ result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
169+ if err != nil {
170+ c.Close()
171+ return nil, nil, fmt.Errorf("list tools: %w", err)
172+ }
173+
174+ return c, result.Tools, nil
175+}
176+
177+// startStdio spawns a stdio-based MCP server.
178+func (s *managedServer) startStdio() (*client.Client, error) {
179+ if s.config.Command == "" {
180+ return nil, fmt.Errorf("stdio transport requires 'command' field")
181+ }
182+
183+ // mcp-go merges this slice with os.Environ() before spawning the subprocess.
184+ env := make([]string, 0, len(s.config.Env))
185+ for k, v := range s.config.Env {
186+ env = append(env, fmt.Sprintf("%s=%s", k, v))
187+ }
188+
189+ // Create stdio client (this spawns the subprocess)
190+ c, err := client.NewStdioMCPClient(s.config.Command, env, s.config.Args...)
191+ if err != nil {
192+ return nil, fmt.Errorf("spawn %s: %w", s.config.Command, err)
193+ }
194+
195+ // Capture stderr for debugging
196+ if stderr, ok := client.GetStderr(c); ok {
197+ go s.pipeStderr(stderr)
198+ }
199+
200+ return c, nil
201+}
202+
203+// startStreamableHTTP connects to an HTTP-based MCP server.
204+func (s *managedServer) startStreamableHTTP(ctx context.Context) (*client.Client, error) {
205+ if s.config.URL == "" {
206+ return nil, fmt.Errorf("streamable-http transport requires 'url' field")
207+ }
208+
209+ // Build HTTP options
210+ opts := []transport.StreamableHTTPCOption{}
211+
212+ // Streamable HTTP requires Accept: application/json, text/event-stream.
213+ // This is a protocol detail, so set it automatically. User-provided
214+ // headers are merged on top for auth/customization.
215+ headers := map[string]string{
216+ "Accept": "application/json, text/event-stream",
217+ }
218+ for k, v := range s.config.Headers {
219+ headers[k] = v
220+ }
221+ opts = append(opts, transport.WithHTTPHeaders(headers))
222+
223+ // Create streamable HTTP client
224+ c, err := client.NewStreamableHttpClient(s.config.URL, opts...)
225+ if err != nil {
226+ return nil, fmt.Errorf("create HTTP client: %w", err)
227+ }
228+
229+ // Start the transport
230+ if err := c.GetTransport().Start(ctx); err != nil {
231+ return nil, fmt.Errorf("start HTTP transport: %w", err)
232+ }
233+
234+ s.logger.Printf("[%s] connected to %s", s.name, s.config.URL)
235+ return c, nil
236+}
237+
238+// startSSE connects to an SSE-based MCP server (legacy transport).
239+func (s *managedServer) startSSE(ctx context.Context) (*client.Client, error) {
240+ if s.config.URL == "" {
241+ return nil, fmt.Errorf("sse transport requires 'url' field")
242+ }
243+
244+ // Create SSE client
245+ c, err := client.NewSSEMCPClient(s.config.URL)
246+ if err != nil {
247+ return nil, fmt.Errorf("create SSE client: %w", err)
248+ }
249+
250+ // Start the transport
251+ if err := c.GetTransport().Start(ctx); err != nil {
252+ return nil, fmt.Errorf("start SSE transport: %w", err)
253+ }
254+
255+ s.logger.Printf("[%s] connected to SSE server at %s", s.name, s.config.URL)
256+ return c, nil
257+}
258+
259+// pipeStderr reads server stderr line by line and logs it.
260+func (s *managedServer) pipeStderr(r io.Reader) {
261+ sc := bufio.NewScanner(r)
262+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
263+ for sc.Scan() {
264+ line := strings.TrimSpace(sc.Text())
265+ if line != "" {
266+ s.logger.Printf("[%s:stderr] %s", s.name, line)
267+ }
268+ }
269+}
270+
271+// stop gracefully shuts down the server.
272+func (s *managedServer) stop() {
273+ s.mu.Lock()
274+ defer s.mu.Unlock()
275+
276+ s.gen++ // invalidate any start attempt still in flight
277+ if s.client != nil {
278+ s.client.Close()
279+ s.client = nil
280+ }
281+ if s.state != stateError {
282+ s.state = stateStopped
283+ }
284+}
285+
286+// callTool forwards a tool call to the MCP server.
287+// If the server is not running, it starts it first.
288+func (s *managedServer) callTool(ctx context.Context, toolName string, args json.RawMessage) (*mcp.CallToolResult, error) {
289+ s.mu.Lock()
290+ c := s.client
291+ st := s.state
292+ s.mu.Unlock()
293+
294+ // If not ready, start the server
295+ if st != stateReady || c == nil {
296+ if err := s.start(ctx); err != nil {
297+ return nil, err
298+ }
299+ s.mu.Lock()
300+ c = s.client
301+ s.mu.Unlock()
302+ if c == nil {
303+ return nil, fmt.Errorf("[%s] stopped before the call could run", s.name)
304+ }
305+ }
306+
307+ // Parse args into map[string]any
308+ var argsMap map[string]any
309+ if len(args) > 0 {
310+ if err := json.Unmarshal(args, &argsMap); err != nil {
311+ return nil, fmt.Errorf("invalid args: %w", err)
312+ }
313+ }
314+
315+ // Call the tool
316+ callCtx, cancel := context.WithTimeout(ctx, time.Duration(s.config.RequestTimeout)*time.Second)
317+ defer cancel()
318+
319+ result, err := c.CallTool(callCtx, mcp.CallToolRequest{
320+ Params: mcp.CallToolParams{
321+ Name: toolName,
322+ Arguments: argsMap,
323+ },
324+ })
325+ if err != nil {
326+ return nil, err
327+ }
328+
329+ // Update last-used time
330+ s.mu.Lock()
331+ s.lastUsed = time.Now()
332+ s.mu.Unlock()
333+
334+ return result, nil
335+}
336+
337+// waitForReady blocks until the server is ready or the context
338+// expires. Polling is a deliberate simplification for this example;
339+// a channel closed by finishStart would avoid the 100ms latency.
340+func (s *managedServer) waitForReady(ctx context.Context) error {
341+ ticker := time.NewTicker(100 * time.Millisecond)
342+ defer ticker.Stop()
343+ for {
344+ select {
345+ case <-ctx.Done():
346+ return ctx.Err()
347+ case <-ticker.C:
348+ s.mu.Lock()
349+ st := s.state
350+ err := s.startErr
351+ s.mu.Unlock()
352+ if st == stateReady {
353+ return nil
354+ }
355+ if st == stateError {
356+ return err
357+ }
358+ }
359+ }
360+}
361+
362+// isIdle returns true if the server hasn't been used within the timeout.
363+func (s *managedServer) isIdle(timeout time.Duration) bool {
364+ s.mu.Lock()
365+ defer s.mu.Unlock()
366+ if s.state != stateReady {
367+ return false
368+ }
369+ return time.Since(s.lastUsed) > timeout
370+}
371+
372+// status returns a compact human-readable status string.
373+func (s *managedServer) status() string {
374+ s.mu.Lock()
375+ defer s.mu.Unlock()
376+
377+ switch s.state {
378+ case stateReady:
379+ return compactToolStatus(s.name, len(s.tools))
380+ case stateError:
381+ if s.startErr != nil {
382+ return fmt.Sprintf("%s (%s)", s.name, compactErr(s.startErr.Error()))
383+ }
384+ return fmt.Sprintf("%s (error)", s.name)
385+ case stateStopped:
386+ if len(s.tools) > 0 {
387+ return fmt.Sprintf("%s (sleeping, %s)", s.name, toolCountText(len(s.tools)))
388+ }
389+ return fmt.Sprintf("%s (sleeping)", s.name)
390+ case stateStarting:
391+ return fmt.Sprintf("%s (starting)", s.name)
392+ default:
393+ return fmt.Sprintf("%s (%s)", s.name, s.state)
394+ }
395+}
396+
397+// detailStatus returns a multi-line status for one server.
398+func (s *managedServer) detailStatus() string {
399+ s.mu.Lock()
400+ name := s.name
401+ transport := s.config.Transport
402+ url := s.config.URL
403+ command := s.config.Command
404+ args := append([]string(nil), s.config.Args...)
405+ state := s.state
406+ toolCount := len(s.tools)
407+ idleTimeout := s.config.IdleTimeout
408+ requestTimeout := s.config.RequestTimeout
409+ connectTimeout := s.config.ConnectTimeout
410+ lastUsed := s.lastUsed
411+ startErr := s.startErr
412+ s.mu.Unlock()
413+
414+ if transport == "" {
415+ transport = "stdio"
416+ }
417+
418+ var sb strings.Builder
419+ sb.WriteString("MCP server: ")
420+ sb.WriteString(name)
421+ sb.WriteByte('\n')
422+ sb.WriteString(" status: ")
423+ sb.WriteString(state.String())
424+ if state == stateError && startErr != nil {
425+ sb.WriteString(" (")
426+ sb.WriteString(compactErr(startErr.Error()))
427+ sb.WriteByte(')')
428+ }
429+ sb.WriteByte('\n')
430+ sb.WriteString(" tools: ")
431+ sb.WriteString(toolCountText(toolCount))
432+ sb.WriteByte('\n')
433+ sb.WriteString(" transport: ")
434+ sb.WriteString(transport)
435+ sb.WriteByte('\n')
436+ if url != "" {
437+ sb.WriteString(" url: ")
438+ sb.WriteString(url)
439+ sb.WriteByte('\n')
440+ }
441+ if command != "" {
442+ sb.WriteString(" command: ")
443+ sb.WriteString(command)
444+ if len(args) > 0 {
445+ sb.WriteByte(' ')
446+ sb.WriteString(strings.Join(args, " "))
447+ }
448+ sb.WriteByte('\n')
449+ }
450+ sb.WriteString(fmt.Sprintf(" timeouts: connect=%ds request=%ds idle=%ds\n", connectTimeout, requestTimeout, idleTimeout))
451+ if !lastUsed.IsZero() {
452+ sb.WriteString(" last used: ")
453+ sb.WriteString(time.Since(lastUsed).Round(time.Second).String())
454+ sb.WriteString(" ago\n")
455+ }
456+ if state == stateError && startErr != nil {
457+ sb.WriteString(" error: ")
458+ sb.WriteString(firstLine(startErr.Error()))
459+ sb.WriteByte('\n')
460+ }
461+ return strings.TrimRight(sb.String(), "\n")
462+}
463+
464+func compactToolStatus(name string, n int) string {
465+ if n <= 0 {
466+ return name
467+ }
468+ return fmt.Sprintf("%s (%s)", name, toolCountText(n))
469+}
470+
471+func toolCountText(n int) string {
472+ if n == 1 {
473+ return "1 tool"
474+ }
475+ return fmt.Sprintf("%d tools", n)
476+}
477+
478+func firstLine(s string) string {
479+ s = strings.TrimSpace(s)
480+ if i := strings.IndexAny(s, "\n\r"); i >= 0 {
481+ s = s[:i]
482+ }
483+ return s
484+}
485+
486+func compactErr(s string) string {
487+ s = firstLine(s)
488+ if s == "" {
489+ return "error"
490+ }
491+ lower := strings.ToLower(s)
492+ switch {
493+ case strings.Contains(lower, "authorization") || strings.Contains(lower, "unauthorized") || strings.Contains(lower, "401") || strings.Contains(lower, "403"):
494+ return "auth failed"
495+ case strings.Contains(lower, "transport") || strings.Contains(lower, "connection") || strings.Contains(lower, "connect"):
496+ return "connection failed"
497+ case strings.Contains(lower, "context deadline exceeded") || strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"):
498+ return "timeout"
499+ case strings.Contains(lower, "no such file") || strings.Contains(lower, "executable file not found"):
500+ return "command not found"
501+ case strings.HasPrefix(lower, "initialize:"):
502+ return "initialize failed"
503+ case strings.HasPrefix(lower, "list tools:"):
504+ return "tool discovery failed"
505+ }
506+ const max = 44
507+ if r := []rune(s); len(r) > max {
508+ s = string(r[:max-1]) + "…"
509+ }
510+ return s
511+}
@@ -0,0 +1,511 @@
1+// server.go — MCP server process lifecycle management.
2+//
3+// Each configured MCP server is wrapped in a managedServer that tracks:
4+// - Connection state (stopped / starting / ready)
5+// - Last-access time (for idle timeout)
6+// - Discovered tools
7+//
8+// Smart lazy: servers are spawned on startup to discover tools, then
9+// killed after an idle period. On the next tool call, they're respawned.
10+package main
11+
12+import (
13+ "bufio"
14+ "context"
15+ "encoding/json"
16+ "fmt"
17+ "io"
18+ "log"
19+ "strings"
20+ "sync"
21+ "time"
22+
23+ "github.com/mark3labs/mcp-go/client"
24+ "github.com/mark3labs/mcp-go/client/transport"
25+ "github.com/mark3labs/mcp-go/mcp"
26+)
27+
28+// serverState tracks the lifecycle of one MCP server.
29+type serverState int
30+
31+const (
32+ stateStopped serverState = iota // not running
33+ stateStarting // spawning / initializing
34+ stateReady // connected, tools discovered
35+ stateError // failed to start
36+)
37+
38+func (s serverState) String() string {
39+ switch s {
40+ case stateStopped:
41+ return "stopped"
42+ case stateStarting:
43+ return "starting"
44+ case stateReady:
45+ return "ready"
46+ case stateError:
47+ return "error"
48+ default:
49+ return "unknown"
50+ }
51+}
52+
53+// managedServer wraps one MCP server with lifecycle management.
54+type managedServer struct {
55+ name string
56+ config ServerConfig
57+ logger *log.Logger
58+
59+ mu sync.Mutex
60+ state serverState
61+ client *client.Client
62+ tools []mcp.Tool
63+ lastUsed time.Time
64+ startErr error
65+ gen uint64 // bumped by stop(); a start attempt only commits if unchanged
66+}
67+
68+// newManagedServer creates a new server wrapper.
69+func newManagedServer(name string, cfg ServerConfig, logger *log.Logger) *managedServer {
70+ return &managedServer{
71+ name: name,
72+ config: cfg,
73+ logger: logger,
74+ state: stateStopped,
75+ lastUsed: time.Now(),
76+ }
77+}
78+
79+// start spawns the MCP server process and discovers its tools.
80+// Safe to call concurrently; a stop() issued while a start is in
81+// flight wins — the late result is discarded and its client closed.
82+func (s *managedServer) start(ctx context.Context) error {
83+ ctx, cancel := context.WithTimeout(ctx, time.Duration(s.config.ConnectTimeout)*time.Second)
84+ defer cancel()
85+
86+ s.mu.Lock()
87+ if s.state == stateReady {
88+ s.lastUsed = time.Now()
89+ s.mu.Unlock()
90+ return nil
91+ }
92+ if s.state == stateStarting {
93+ s.mu.Unlock()
94+ return s.waitForReady(ctx)
95+ }
96+ s.state = stateStarting
97+ s.startErr = nil
98+ gen := s.gen
99+ s.mu.Unlock()
100+
101+ c, tools, err := s.doStart(ctx)
102+ return s.finishStart(gen, c, tools, err)
103+}
104+
105+// finishStart commits the outcome of a start attempt, unless stop()
106+// bumped the generation in the meantime.
107+func (s *managedServer) finishStart(gen uint64, c *client.Client, tools []mcp.Tool, err error) error {
108+ s.mu.Lock()
109+ defer s.mu.Unlock()
110+
111+ if s.gen != gen {
112+ if c != nil {
113+ c.Close()
114+ }
115+ return fmt.Errorf("[%s] stopped while starting", s.name)
116+ }
117+ if err != nil {
118+ s.state = stateError
119+ s.startErr = err
120+ s.logger.Printf("[%s] start failed: %v", s.name, err)
121+ return err
122+ }
123+ s.client = c
124+ s.tools = tools
125+ s.state = stateReady
126+ s.lastUsed = time.Now()
127+ s.logger.Printf("[%s] ready with %d tools", s.name, len(s.tools))
128+ return nil
129+}
130+
131+// doStart performs the actual spawn + initialize + tools/list.
132+func (s *managedServer) doStart(ctx context.Context) (*client.Client, []mcp.Tool, error) {
133+ var c *client.Client
134+ var err error
135+
136+ switch s.config.Transport {
137+ case "stdio", "":
138+ c, err = s.startStdio()
139+ case "streamable-http":
140+ c, err = s.startStreamableHTTP(ctx)
141+ case "sse":
142+ c, err = s.startSSE(ctx)
143+ default:
144+ return nil, nil, fmt.Errorf("unknown transport: %s", s.config.Transport)
145+ }
146+
147+ if err != nil {
148+ return nil, nil, err
149+ }
150+
151+ // Initialize the MCP session
152+ _, err = c.Initialize(ctx, mcp.InitializeRequest{
153+ Params: mcp.InitializeParams{
154+ ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
155+ ClientInfo: mcp.Implementation{
156+ Name: "zot-mcp-bridge",
157+ Version: "1.0.0",
158+ },
159+ Capabilities: mcp.ClientCapabilities{},
160+ },
161+ })
162+ if err != nil {
163+ c.Close()
164+ return nil, nil, fmt.Errorf("initialize: %w", err)
165+ }
166+
167+ // Discover tools
168+ result, err := c.ListTools(ctx, mcp.ListToolsRequest{})
169+ if err != nil {
170+ c.Close()
171+ return nil, nil, fmt.Errorf("list tools: %w", err)
172+ }
173+
174+ return c, result.Tools, nil
175+}
176+
177+// startStdio spawns a stdio-based MCP server.
178+func (s *managedServer) startStdio() (*client.Client, error) {
179+ if s.config.Command == "" {
180+ return nil, fmt.Errorf("stdio transport requires 'command' field")
181+ }
182+
183+ // mcp-go merges this slice with os.Environ() before spawning the subprocess.
184+ env := make([]string, 0, len(s.config.Env))
185+ for k, v := range s.config.Env {
186+ env = append(env, fmt.Sprintf("%s=%s", k, v))
187+ }
188+
189+ // Create stdio client (this spawns the subprocess)
190+ c, err := client.NewStdioMCPClient(s.config.Command, env, s.config.Args...)
191+ if err != nil {
192+ return nil, fmt.Errorf("spawn %s: %w", s.config.Command, err)
193+ }
194+
195+ // Capture stderr for debugging
196+ if stderr, ok := client.GetStderr(c); ok {
197+ go s.pipeStderr(stderr)
198+ }
199+
200+ return c, nil
201+}
202+
203+// startStreamableHTTP connects to an HTTP-based MCP server.
204+func (s *managedServer) startStreamableHTTP(ctx context.Context) (*client.Client, error) {
205+ if s.config.URL == "" {
206+ return nil, fmt.Errorf("streamable-http transport requires 'url' field")
207+ }
208+
209+ // Build HTTP options
210+ opts := []transport.StreamableHTTPCOption{}
211+
212+ // Streamable HTTP requires Accept: application/json, text/event-stream.
213+ // This is a protocol detail, so set it automatically. User-provided
214+ // headers are merged on top for auth/customization.
215+ headers := map[string]string{
216+ "Accept": "application/json, text/event-stream",
217+ }
218+ for k, v := range s.config.Headers {
219+ headers[k] = v
220+ }
221+ opts = append(opts, transport.WithHTTPHeaders(headers))
222+
223+ // Create streamable HTTP client
224+ c, err := client.NewStreamableHttpClient(s.config.URL, opts...)
225+ if err != nil {
226+ return nil, fmt.Errorf("create HTTP client: %w", err)
227+ }
228+
229+ // Start the transport
230+ if err := c.GetTransport().Start(ctx); err != nil {
231+ return nil, fmt.Errorf("start HTTP transport: %w", err)
232+ }
233+
234+ s.logger.Printf("[%s] connected to %s", s.name, s.config.URL)
235+ return c, nil
236+}
237+
238+// startSSE connects to an SSE-based MCP server (legacy transport).
239+func (s *managedServer) startSSE(ctx context.Context) (*client.Client, error) {
240+ if s.config.URL == "" {
241+ return nil, fmt.Errorf("sse transport requires 'url' field")
242+ }
243+
244+ // Create SSE client
245+ c, err := client.NewSSEMCPClient(s.config.URL)
246+ if err != nil {
247+ return nil, fmt.Errorf("create SSE client: %w", err)
248+ }
249+
250+ // Start the transport
251+ if err := c.GetTransport().Start(ctx); err != nil {
252+ return nil, fmt.Errorf("start SSE transport: %w", err)
253+ }
254+
255+ s.logger.Printf("[%s] connected to SSE server at %s", s.name, s.config.URL)
256+ return c, nil
257+}
258+
259+// pipeStderr reads server stderr line by line and logs it.
260+func (s *managedServer) pipeStderr(r io.Reader) {
261+ sc := bufio.NewScanner(r)
262+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
263+ for sc.Scan() {
264+ line := strings.TrimSpace(sc.Text())
265+ if line != "" {
266+ s.logger.Printf("[%s:stderr] %s", s.name, line)
267+ }
268+ }
269+}
270+
271+// stop gracefully shuts down the server.
272+func (s *managedServer) stop() {
273+ s.mu.Lock()
274+ defer s.mu.Unlock()
275+
276+ s.gen++ // invalidate any start attempt still in flight
277+ if s.client != nil {
278+ s.client.Close()
279+ s.client = nil
280+ }
281+ if s.state != stateError {
282+ s.state = stateStopped
283+ }
284+}
285+
286+// callTool forwards a tool call to the MCP server.
287+// If the server is not running, it starts it first.
288+func (s *managedServer) callTool(ctx context.Context, toolName string, args json.RawMessage) (*mcp.CallToolResult, error) {
289+ s.mu.Lock()
290+ c := s.client
291+ st := s.state
292+ s.mu.Unlock()
293+
294+ // If not ready, start the server
295+ if st != stateReady || c == nil {
296+ if err := s.start(ctx); err != nil {
297+ return nil, err
298+ }
299+ s.mu.Lock()
300+ c = s.client
301+ s.mu.Unlock()
302+ if c == nil {
303+ return nil, fmt.Errorf("[%s] stopped before the call could run", s.name)
304+ }
305+ }
306+
307+ // Parse args into map[string]any
308+ var argsMap map[string]any
309+ if len(args) > 0 {
310+ if err := json.Unmarshal(args, &argsMap); err != nil {
311+ return nil, fmt.Errorf("invalid args: %w", err)
312+ }
313+ }
314+
315+ // Call the tool
316+ callCtx, cancel := context.WithTimeout(ctx, time.Duration(s.config.RequestTimeout)*time.Second)
317+ defer cancel()
318+
319+ result, err := c.CallTool(callCtx, mcp.CallToolRequest{
320+ Params: mcp.CallToolParams{
321+ Name: toolName,
322+ Arguments: argsMap,
323+ },
324+ })
325+ if err != nil {
326+ return nil, err
327+ }
328+
329+ // Update last-used time
330+ s.mu.Lock()
331+ s.lastUsed = time.Now()
332+ s.mu.Unlock()
333+
334+ return result, nil
335+}
336+
337+// waitForReady blocks until the server is ready or the context
338+// expires. Polling is a deliberate simplification for this example;
339+// a channel closed by finishStart would avoid the 100ms latency.
340+func (s *managedServer) waitForReady(ctx context.Context) error {
341+ ticker := time.NewTicker(100 * time.Millisecond)
342+ defer ticker.Stop()
343+ for {
344+ select {
345+ case <-ctx.Done():
346+ return ctx.Err()
347+ case <-ticker.C:
348+ s.mu.Lock()
349+ st := s.state
350+ err := s.startErr
351+ s.mu.Unlock()
352+ if st == stateReady {
353+ return nil
354+ }
355+ if st == stateError {
356+ return err
357+ }
358+ }
359+ }
360+}
361+
362+// isIdle returns true if the server hasn't been used within the timeout.
363+func (s *managedServer) isIdle(timeout time.Duration) bool {
364+ s.mu.Lock()
365+ defer s.mu.Unlock()
366+ if s.state != stateReady {
367+ return false
368+ }
369+ return time.Since(s.lastUsed) > timeout
370+}
371+
372+// status returns a compact human-readable status string.
373+func (s *managedServer) status() string {
374+ s.mu.Lock()
375+ defer s.mu.Unlock()
376+
377+ switch s.state {
378+ case stateReady:
379+ return compactToolStatus(s.name, len(s.tools))
380+ case stateError:
381+ if s.startErr != nil {
382+ return fmt.Sprintf("%s (%s)", s.name, compactErr(s.startErr.Error()))
383+ }
384+ return fmt.Sprintf("%s (error)", s.name)
385+ case stateStopped:
386+ if len(s.tools) > 0 {
387+ return fmt.Sprintf("%s (sleeping, %s)", s.name, toolCountText(len(s.tools)))
388+ }
389+ return fmt.Sprintf("%s (sleeping)", s.name)
390+ case stateStarting:
391+ return fmt.Sprintf("%s (starting)", s.name)
392+ default:
393+ return fmt.Sprintf("%s (%s)", s.name, s.state)
394+ }
395+}
396+
397+// detailStatus returns a multi-line status for one server.
398+func (s *managedServer) detailStatus() string {
399+ s.mu.Lock()
400+ name := s.name
401+ transport := s.config.Transport
402+ url := s.config.URL
403+ command := s.config.Command
404+ args := append([]string(nil), s.config.Args...)
405+ state := s.state
406+ toolCount := len(s.tools)
407+ idleTimeout := s.config.IdleTimeout
408+ requestTimeout := s.config.RequestTimeout
409+ connectTimeout := s.config.ConnectTimeout
410+ lastUsed := s.lastUsed
411+ startErr := s.startErr
412+ s.mu.Unlock()
413+
414+ if transport == "" {
415+ transport = "stdio"
416+ }
417+
418+ var sb strings.Builder
419+ sb.WriteString("MCP server: ")
420+ sb.WriteString(name)
421+ sb.WriteByte('\n')
422+ sb.WriteString(" status: ")
423+ sb.WriteString(state.String())
424+ if state == stateError && startErr != nil {
425+ sb.WriteString(" (")
426+ sb.WriteString(compactErr(startErr.Error()))
427+ sb.WriteByte(')')
428+ }
429+ sb.WriteByte('\n')
430+ sb.WriteString(" tools: ")
431+ sb.WriteString(toolCountText(toolCount))
432+ sb.WriteByte('\n')
433+ sb.WriteString(" transport: ")
434+ sb.WriteString(transport)
435+ sb.WriteByte('\n')
436+ if url != "" {
437+ sb.WriteString(" url: ")
438+ sb.WriteString(url)
439+ sb.WriteByte('\n')
440+ }
441+ if command != "" {
442+ sb.WriteString(" command: ")
443+ sb.WriteString(command)
444+ if len(args) > 0 {
445+ sb.WriteByte(' ')
446+ sb.WriteString(strings.Join(args, " "))
447+ }
448+ sb.WriteByte('\n')
449+ }
450+ sb.WriteString(fmt.Sprintf(" timeouts: connect=%ds request=%ds idle=%ds\n", connectTimeout, requestTimeout, idleTimeout))
451+ if !lastUsed.IsZero() {
452+ sb.WriteString(" last used: ")
453+ sb.WriteString(time.Since(lastUsed).Round(time.Second).String())
454+ sb.WriteString(" ago\n")
455+ }
456+ if state == stateError && startErr != nil {
457+ sb.WriteString(" error: ")
458+ sb.WriteString(firstLine(startErr.Error()))
459+ sb.WriteByte('\n')
460+ }
461+ return strings.TrimRight(sb.String(), "\n")
462+}
463+
464+func compactToolStatus(name string, n int) string {
465+ if n <= 0 {
466+ return name
467+ }
468+ return fmt.Sprintf("%s (%s)", name, toolCountText(n))
469+}
470+
471+func toolCountText(n int) string {
472+ if n == 1 {
473+ return "1 tool"
474+ }
475+ return fmt.Sprintf("%d tools", n)
476+}
477+
478+func firstLine(s string) string {
479+ s = strings.TrimSpace(s)
480+ if i := strings.IndexAny(s, "\n\r"); i >= 0 {
481+ s = s[:i]
482+ }
483+ return s
484+}
485+
486+func compactErr(s string) string {
487+ s = firstLine(s)
488+ if s == "" {
489+ return "error"
490+ }
491+ lower := strings.ToLower(s)
492+ switch {
493+ case strings.Contains(lower, "authorization") || strings.Contains(lower, "unauthorized") || strings.Contains(lower, "401") || strings.Contains(lower, "403"):
494+ return "auth failed"
495+ case strings.Contains(lower, "transport") || strings.Contains(lower, "connection") || strings.Contains(lower, "connect"):
496+ return "connection failed"
497+ case strings.Contains(lower, "context deadline exceeded") || strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"):
498+ return "timeout"
499+ case strings.Contains(lower, "no such file") || strings.Contains(lower, "executable file not found"):
500+ return "command not found"
501+ case strings.HasPrefix(lower, "initialize:"):
502+ return "initialize failed"
503+ case strings.HasPrefix(lower, "list tools:"):
504+ return "tool discovery failed"
505+ }
506+ const max = 44
507+ if r := []rune(s); len(r) > max {
508+ s = string(r[:max-1]) + "…"
509+ }
510+ return s
511+}
A examples/extensions/mcp-bridge/setup.go +191 -0
@@ -0,0 +1,191 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+)
13+
14+type serverTemplate struct {
15+ Name string
16+ Description string
17+ Config func(cwd string) ServerConfig
18+}
19+
20+func setupTemplates() map[string]serverTemplate {
21+ return map[string]serverTemplate{
22+ "grep": {
23+ Name: "grep",
24+ Description: "Search real-world code across public GitHub repositories via grep.app.",
25+ Config: func(cwd string) ServerConfig {
26+ return ServerConfig{
27+ Transport: "streamable-http",
28+ URL: "https://mcp.grep.app/",
29+ }
30+ },
31+ },
32+ "filesystem": {
33+ Name: "filesystem",
34+ Description: "Read/write files under the current project directory using the official filesystem MCP server.",
35+ Config: func(cwd string) ServerConfig {
36+ return ServerConfig{
37+ Transport: "stdio",
38+ Command: "npx",
39+ Args: []string{"-y", "@modelcontextprotocol/server-filesystem", cwd},
40+ }
41+ },
42+ },
43+ "context7": {
44+ Name: "context7",
45+ Description: "Fetch up-to-date library documentation and examples via Context7.",
46+ Config: func(cwd string) ServerConfig {
47+ return ServerConfig{
48+ Transport: "stdio",
49+ Command: "npx",
50+ Args: []string{"-y", "@upstash/context7-mcp@latest"},
51+ }
52+ },
53+ },
54+ "playwright": {
55+ Name: "playwright",
56+ Description: "Browser automation via Playwright MCP.",
57+ Config: func(cwd string) ServerConfig {
58+ return ServerConfig{
59+ Transport: "stdio",
60+ Command: "npx",
61+ Args: []string{"-y", "@executeautomation/playwright-mcp-server"},
62+ }
63+ },
64+ },
65+ }
66+}
67+
68+func setupHelp() string {
69+ templates := setupTemplates()
70+ names := make([]string, 0, len(templates))
71+ for name := range templates {
72+ names = append(names, name)
73+ }
74+ sort.Strings(names)
75+
76+ var b strings.Builder
77+ b.WriteString("mcp-bridge setup\n\n")
78+ b.WriteString("Add a known MCP server template to your zot MCP config.\n\n")
79+ b.WriteString("Usage:\n")
80+ b.WriteString(" /mcp setup add <template> [--global|--project] [--name <server-name>]\n")
81+ b.WriteString(" /mcp setup templates\n\n")
82+ b.WriteString("Templates:\n")
83+ for _, name := range names {
84+ t := templates[name]
85+ b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, t.Description))
86+ }
87+ b.WriteString("\nExamples:\n")
88+ b.WriteString(" /mcp setup add grep\n")
89+ b.WriteString(" /mcp setup add filesystem --project\n")
90+ b.WriteString(" /mcp setup add context7 --global --name docs\n")
91+ b.WriteString("\nDefault target is global: $ZOT_HOME/mcp.json. Run /reload-ext after changes.\n")
92+ return b.String()
93+}
94+
95+func handleSetup(args []string, cwd string) (string, error) {
96+ if len(args) == 0 || args[0] == "help" {
97+ return setupHelp(), nil
98+ }
99+ if args[0] == "templates" || args[0] == "list" {
100+ return setupHelp(), nil
101+ }
102+ if args[0] != "add" {
103+ return "", fmt.Errorf("unknown setup command %q\n\n%s", args[0], setupHelp())
104+ }
105+ if len(args) < 2 {
106+ return "", fmt.Errorf("usage: /mcp setup add <template> [--global|--project] [--name <server-name>]")
107+ }
108+
109+ templateName := args[1]
110+ templates := setupTemplates()
111+ t, ok := templates[templateName]
112+ if !ok {
113+ return "", fmt.Errorf("unknown template %q\n\n%s", templateName, setupHelp())
114+ }
115+
116+ target := "global"
117+ serverName := templateName
118+ for i := 2; i < len(args); i++ {
119+ switch args[i] {
120+ case "--global":
121+ target = "global"
122+ case "--project":
123+ target = "project"
124+ case "--name":
125+ if i+1 >= len(args) {
126+ return "", fmt.Errorf("--name requires a value")
127+ }
128+ serverName = args[i+1]
129+ i++
130+ default:
131+ return "", fmt.Errorf("unknown setup option %q", args[i])
132+ }
133+ }
134+
135+ path := filepath.Join(zotHome(), "mcp.json")
136+ if target == "project" {
137+ if cwd == "" {
138+ return "", fmt.Errorf("--project requires a working directory, but none is known")
139+ }
140+ path = filepath.Join(cwd, ".zot", "mcp.json")
141+ }
142+
143+ cfg, err := readConfigFile(path)
144+ if err != nil {
145+ return "", err
146+ }
147+ if cfg.MCPServers == nil {
148+ cfg.MCPServers = map[string]ServerConfig{}
149+ }
150+ if _, exists := cfg.MCPServers[serverName]; exists {
151+ return "", fmt.Errorf("server %q already exists in %s", serverName, path)
152+ }
153+
154+ cfg.MCPServers[serverName] = t.Config(cwd)
155+ if err := writeConfigFile(path, cfg); err != nil {
156+ return "", err
157+ }
158+
159+ return fmt.Sprintf("Added MCP server %q from template %q to %s.\n\nRun /reload-ext to reload MCP tools.", serverName, templateName, path), nil
160+}
161+
162+func readConfigFile(path string) (Config, error) {
163+ cfg := Config{MCPServers: map[string]ServerConfig{}}
164+ data, err := os.ReadFile(path)
165+ if err != nil {
166+ if errors.Is(err, fs.ErrNotExist) {
167+ return cfg, nil
168+ }
169+ return cfg, err
170+ }
171+ if len(strings.TrimSpace(string(data))) == 0 {
172+ return cfg, nil
173+ }
174+ if err := json.Unmarshal(data, &cfg); err != nil {
175+ return cfg, fmt.Errorf("parse %s: %w", path, err)
176+ }
177+ if cfg.MCPServers == nil {
178+ cfg.MCPServers = map[string]ServerConfig{}
179+ }
180+ return cfg, nil
181+}
182+
183+func writeConfigFile(path string, cfg Config) error {
184+ data, err := json.MarshalIndent(cfg, "", " ")
185+ if err != nil {
186+ return err
187+ }
188+ data = append(data, '\n')
189+ // 0o600: mcp.json can contain auth headers / tokens.
190+ return writeFileAtomic(path, data, 0o600)
191+}
@@ -0,0 +1,191 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "io/fs"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+)
13+
14+type serverTemplate struct {
15+ Name string
16+ Description string
17+ Config func(cwd string) ServerConfig
18+}
19+
20+func setupTemplates() map[string]serverTemplate {
21+ return map[string]serverTemplate{
22+ "grep": {
23+ Name: "grep",
24+ Description: "Search real-world code across public GitHub repositories via grep.app.",
25+ Config: func(cwd string) ServerConfig {
26+ return ServerConfig{
27+ Transport: "streamable-http",
28+ URL: "https://mcp.grep.app/",
29+ }
30+ },
31+ },
32+ "filesystem": {
33+ Name: "filesystem",
34+ Description: "Read/write files under the current project directory using the official filesystem MCP server.",
35+ Config: func(cwd string) ServerConfig {
36+ return ServerConfig{
37+ Transport: "stdio",
38+ Command: "npx",
39+ Args: []string{"-y", "@modelcontextprotocol/server-filesystem", cwd},
40+ }
41+ },
42+ },
43+ "context7": {
44+ Name: "context7",
45+ Description: "Fetch up-to-date library documentation and examples via Context7.",
46+ Config: func(cwd string) ServerConfig {
47+ return ServerConfig{
48+ Transport: "stdio",
49+ Command: "npx",
50+ Args: []string{"-y", "@upstash/context7-mcp@latest"},
51+ }
52+ },
53+ },
54+ "playwright": {
55+ Name: "playwright",
56+ Description: "Browser automation via Playwright MCP.",
57+ Config: func(cwd string) ServerConfig {
58+ return ServerConfig{
59+ Transport: "stdio",
60+ Command: "npx",
61+ Args: []string{"-y", "@executeautomation/playwright-mcp-server"},
62+ }
63+ },
64+ },
65+ }
66+}
67+
68+func setupHelp() string {
69+ templates := setupTemplates()
70+ names := make([]string, 0, len(templates))
71+ for name := range templates {
72+ names = append(names, name)
73+ }
74+ sort.Strings(names)
75+
76+ var b strings.Builder
77+ b.WriteString("mcp-bridge setup\n\n")
78+ b.WriteString("Add a known MCP server template to your zot MCP config.\n\n")
79+ b.WriteString("Usage:\n")
80+ b.WriteString(" /mcp setup add <template> [--global|--project] [--name <server-name>]\n")
81+ b.WriteString(" /mcp setup templates\n\n")
82+ b.WriteString("Templates:\n")
83+ for _, name := range names {
84+ t := templates[name]
85+ b.WriteString(fmt.Sprintf(" %-12s %s\n", t.Name, t.Description))
86+ }
87+ b.WriteString("\nExamples:\n")
88+ b.WriteString(" /mcp setup add grep\n")
89+ b.WriteString(" /mcp setup add filesystem --project\n")
90+ b.WriteString(" /mcp setup add context7 --global --name docs\n")
91+ b.WriteString("\nDefault target is global: $ZOT_HOME/mcp.json. Run /reload-ext after changes.\n")
92+ return b.String()
93+}
94+
95+func handleSetup(args []string, cwd string) (string, error) {
96+ if len(args) == 0 || args[0] == "help" {
97+ return setupHelp(), nil
98+ }
99+ if args[0] == "templates" || args[0] == "list" {
100+ return setupHelp(), nil
101+ }
102+ if args[0] != "add" {
103+ return "", fmt.Errorf("unknown setup command %q\n\n%s", args[0], setupHelp())
104+ }
105+ if len(args) < 2 {
106+ return "", fmt.Errorf("usage: /mcp setup add <template> [--global|--project] [--name <server-name>]")
107+ }
108+
109+ templateName := args[1]
110+ templates := setupTemplates()
111+ t, ok := templates[templateName]
112+ if !ok {
113+ return "", fmt.Errorf("unknown template %q\n\n%s", templateName, setupHelp())
114+ }
115+
116+ target := "global"
117+ serverName := templateName
118+ for i := 2; i < len(args); i++ {
119+ switch args[i] {
120+ case "--global":
121+ target = "global"
122+ case "--project":
123+ target = "project"
124+ case "--name":
125+ if i+1 >= len(args) {
126+ return "", fmt.Errorf("--name requires a value")
127+ }
128+ serverName = args[i+1]
129+ i++
130+ default:
131+ return "", fmt.Errorf("unknown setup option %q", args[i])
132+ }
133+ }
134+
135+ path := filepath.Join(zotHome(), "mcp.json")
136+ if target == "project" {
137+ if cwd == "" {
138+ return "", fmt.Errorf("--project requires a working directory, but none is known")
139+ }
140+ path = filepath.Join(cwd, ".zot", "mcp.json")
141+ }
142+
143+ cfg, err := readConfigFile(path)
144+ if err != nil {
145+ return "", err
146+ }
147+ if cfg.MCPServers == nil {
148+ cfg.MCPServers = map[string]ServerConfig{}
149+ }
150+ if _, exists := cfg.MCPServers[serverName]; exists {
151+ return "", fmt.Errorf("server %q already exists in %s", serverName, path)
152+ }
153+
154+ cfg.MCPServers[serverName] = t.Config(cwd)
155+ if err := writeConfigFile(path, cfg); err != nil {
156+ return "", err
157+ }
158+
159+ return fmt.Sprintf("Added MCP server %q from template %q to %s.\n\nRun /reload-ext to reload MCP tools.", serverName, templateName, path), nil
160+}
161+
162+func readConfigFile(path string) (Config, error) {
163+ cfg := Config{MCPServers: map[string]ServerConfig{}}
164+ data, err := os.ReadFile(path)
165+ if err != nil {
166+ if errors.Is(err, fs.ErrNotExist) {
167+ return cfg, nil
168+ }
169+ return cfg, err
170+ }
171+ if len(strings.TrimSpace(string(data))) == 0 {
172+ return cfg, nil
173+ }
174+ if err := json.Unmarshal(data, &cfg); err != nil {
175+ return cfg, fmt.Errorf("parse %s: %w", path, err)
176+ }
177+ if cfg.MCPServers == nil {
178+ cfg.MCPServers = map[string]ServerConfig{}
179+ }
180+ return cfg, nil
181+}
182+
183+func writeConfigFile(path string, cfg Config) error {
184+ data, err := json.MarshalIndent(cfg, "", " ")
185+ if err != nil {
186+ return err
187+ }
188+ data = append(data, '\n')
189+ // 0o600: mcp.json can contain auth headers / tokens.
190+ return writeFileAtomic(path, data, 0o600)
191+}
A examples/extensions/mcp-bridge/setup_test.go +71 -0
@@ -0,0 +1,71 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestHandleSetupAddGrepGlobal(t *testing.T) {
11+ tmp := t.TempDir()
12+ t.Setenv("ZOT_HOME", tmp)
13+
14+ out, err := handleSetup([]string{"add", "grep"}, tmp)
15+ if err != nil {
16+ t.Fatalf("handleSetup add grep: %v", err)
17+ }
18+ if out == "" {
19+ t.Fatal("expected setup output")
20+ }
21+
22+ data, err := os.ReadFile(filepath.Join(tmp, "mcp.json"))
23+ if err != nil {
24+ t.Fatalf("read mcp.json: %v", err)
25+ }
26+ var cfg Config
27+ if err := json.Unmarshal(data, &cfg); err != nil {
28+ t.Fatalf("parse mcp.json: %v", err)
29+ }
30+ grep := cfg.MCPServers["grep"]
31+ if grep.Transport != "streamable-http" || grep.URL != "https://mcp.grep.app/" {
32+ t.Fatalf("unexpected grep config: %+v", grep)
33+ }
34+ if len(grep.Headers) != 0 {
35+ t.Fatalf("protocol headers must not be written to mcp.json: %+v", grep.Headers)
36+ }
37+}
38+
39+func TestHandleSetupAddFilesystemProject(t *testing.T) {
40+ cwd := t.TempDir()
41+
42+ _, err := handleSetup([]string{"add", "filesystem", "--project"}, cwd)
43+ if err != nil {
44+ t.Fatalf("handleSetup add filesystem: %v", err)
45+ }
46+
47+ data, err := os.ReadFile(filepath.Join(cwd, ".zot", "mcp.json"))
48+ if err != nil {
49+ t.Fatalf("read project mcp.json: %v", err)
50+ }
51+ var cfg Config
52+ if err := json.Unmarshal(data, &cfg); err != nil {
53+ t.Fatalf("parse project mcp.json: %v", err)
54+ }
55+ fs := cfg.MCPServers["filesystem"]
56+ if fs.Command != "npx" || len(fs.Args) != 3 || fs.Args[2] != cwd {
57+ t.Fatalf("unexpected filesystem config: %+v", fs)
58+ }
59+}
60+
61+func TestHandleSetupDuplicate(t *testing.T) {
62+ tmp := t.TempDir()
63+ t.Setenv("ZOT_HOME", tmp)
64+
65+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err != nil {
66+ t.Fatalf("first add failed: %v", err)
67+ }
68+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err == nil {
69+ t.Fatal("expected duplicate server error")
70+ }
71+}
@@ -0,0 +1,71 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestHandleSetupAddGrepGlobal(t *testing.T) {
11+ tmp := t.TempDir()
12+ t.Setenv("ZOT_HOME", tmp)
13+
14+ out, err := handleSetup([]string{"add", "grep"}, tmp)
15+ if err != nil {
16+ t.Fatalf("handleSetup add grep: %v", err)
17+ }
18+ if out == "" {
19+ t.Fatal("expected setup output")
20+ }
21+
22+ data, err := os.ReadFile(filepath.Join(tmp, "mcp.json"))
23+ if err != nil {
24+ t.Fatalf("read mcp.json: %v", err)
25+ }
26+ var cfg Config
27+ if err := json.Unmarshal(data, &cfg); err != nil {
28+ t.Fatalf("parse mcp.json: %v", err)
29+ }
30+ grep := cfg.MCPServers["grep"]
31+ if grep.Transport != "streamable-http" || grep.URL != "https://mcp.grep.app/" {
32+ t.Fatalf("unexpected grep config: %+v", grep)
33+ }
34+ if len(grep.Headers) != 0 {
35+ t.Fatalf("protocol headers must not be written to mcp.json: %+v", grep.Headers)
36+ }
37+}
38+
39+func TestHandleSetupAddFilesystemProject(t *testing.T) {
40+ cwd := t.TempDir()
41+
42+ _, err := handleSetup([]string{"add", "filesystem", "--project"}, cwd)
43+ if err != nil {
44+ t.Fatalf("handleSetup add filesystem: %v", err)
45+ }
46+
47+ data, err := os.ReadFile(filepath.Join(cwd, ".zot", "mcp.json"))
48+ if err != nil {
49+ t.Fatalf("read project mcp.json: %v", err)
50+ }
51+ var cfg Config
52+ if err := json.Unmarshal(data, &cfg); err != nil {
53+ t.Fatalf("parse project mcp.json: %v", err)
54+ }
55+ fs := cfg.MCPServers["filesystem"]
56+ if fs.Command != "npx" || len(fs.Args) != 3 || fs.Args[2] != cwd {
57+ t.Fatalf("unexpected filesystem config: %+v", fs)
58+ }
59+}
60+
61+func TestHandleSetupDuplicate(t *testing.T) {
62+ tmp := t.TempDir()
63+ t.Setenv("ZOT_HOME", tmp)
64+
65+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err != nil {
66+ t.Fatalf("first add failed: %v", err)
67+ }
68+ if _, err := handleSetup([]string{"add", "grep"}, tmp); err == nil {
69+ t.Fatal("expected duplicate server error")
70+ }
71+}
A examples/extensions/mcp-bridge/status_test.go +222 -0
@@ -0,0 +1,222 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "log"
10+ "strings"
11+ "testing"
12+ "testing/iotest"
13+ "unicode/utf8"
14+
15+ "github.com/mark3labs/mcp-go/mcp"
16+)
17+
18+func TestManagedServerStatusCompact(t *testing.T) {
19+ s := newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
20+
21+ s.state = stateReady
22+ s.tools = []mcp.Tool{{Name: "searchGitHub"}}
23+ if got, want := s.status(), "grep (1 tool)"; got != want {
24+ t.Fatalf("ready status = %q, want %q", got, want)
25+ }
26+
27+ s.state = stateStopped
28+ if got, want := s.status(), "grep (sleeping, 1 tool)"; got != want {
29+ t.Fatalf("stopped status = %q, want %q", got, want)
30+ }
31+
32+ s.state = stateError
33+ s.startErr = errors.New("authorization required")
34+ if got, want := s.status(), "grep (auth failed)"; got != want {
35+ t.Fatalf("error status = %q, want %q", got, want)
36+ }
37+}
38+
39+func TestFormatStatusSummaryCompact(t *testing.T) {
40+ b := &bridge{servers: map[string]*managedServer{}}
41+ b.servers["grep"] = newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
42+ b.servers["grep"].state = stateReady
43+ b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
44+ b.servers["n8n-mcp"] = newManagedServer("n8n-mcp", ServerConfig{}, log.New(io.Discard, "", 0))
45+ b.servers["n8n-mcp"].state = stateReady
46+ b.servers["n8n-mcp"].tools = make([]mcp.Tool, 26)
47+
48+ if got, want := formatStatusSummary(b), "grep (1 tool) | n8n-mcp (26 tools)"; got != want {
49+ t.Fatalf("summary = %q, want %q", got, want)
50+ }
51+ if got, want := b.notifyLevel(), "success"; got != want {
52+ t.Fatalf("notify level = %q, want %q", got, want)
53+ }
54+}
55+
56+func TestManagedServerStopBeforeStartDoesNotPanic(t *testing.T) {
57+ s := newManagedServer("never-started", ServerConfig{}, log.New(io.Discard, "", 0))
58+ s.stop()
59+ if got, want := s.state, stateStopped; got != want {
60+ t.Fatalf("state = %s, want %s", got, want)
61+ }
62+}
63+
64+func TestMCPToolSchemaPreservesExtraFields(t *testing.T) {
65+ schema := mcpToolSchema(mcp.Tool{
66+ Name: "query",
67+ InputSchema: mcp.ToolInputSchema{
68+ Type: "object",
69+ Properties: map[string]any{"sql": map[string]any{"type": "string"}},
70+ Required: []string{"sql"},
71+ Defs: map[string]any{"Thing": map[string]any{"type": "object"}},
72+ AdditionalProperties: false,
73+ },
74+ })
75+
76+ data, err := json.Marshal(schema)
77+ if err != nil {
78+ t.Fatalf("marshal schema: %v", err)
79+ }
80+ var got map[string]any
81+ if err := json.Unmarshal(data, &got); err != nil {
82+ t.Fatalf("unmarshal schema: %v", err)
83+ }
84+ if _, ok := got["$defs"]; !ok {
85+ t.Fatalf("schema did not preserve $defs: %#v", got)
86+ }
87+ if got["additionalProperties"] != false {
88+ t.Fatalf("schema did not preserve additionalProperties=false: %#v", got)
89+ }
90+}
91+
92+func TestCompactErrShortensConnectionFailures(t *testing.T) {
93+ msg := "initialize: transport error: failed to send request: failed to send request"
94+ if got, want := compactErr(msg), "connection failed"; got != want {
95+ t.Fatalf("compactErr = %q, want %q", got, want)
96+ }
97+}
98+
99+func TestNotifyLevelWarnOnPartialFailure(t *testing.T) {
100+ b := &bridge{servers: map[string]*managedServer{}}
101+ b.servers["grep"] = newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
102+ b.servers["grep"].state = stateReady
103+ b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
104+ b.servers["broken"] = newManagedServer("broken", ServerConfig{}, log.New(io.Discard, "", 0))
105+ b.servers["broken"].state = stateError
106+ b.servers["broken"].startErr = errors.New("authorization required\nmore detail")
107+
108+ if got, want := formatStatusSummary(b), "broken (auth failed) | grep (1 tool)"; got != want {
109+ t.Fatalf("summary = %q, want %q", got, want)
110+ }
111+ if got, want := b.notifyLevel(), "warn"; got != want {
112+ t.Fatalf("notify level = %q, want %q", got, want)
113+ }
114+}
115+
116+func TestStopDuringStartDiscardsStaleResult(t *testing.T) {
117+ s := newManagedServer("racy", ServerConfig{}, log.New(io.Discard, "", 0))
118+
119+ // Simulate the window inside start(): state is starting, gen recorded.
120+ s.mu.Lock()
121+ s.state = stateStarting
122+ gen := s.gen
123+ s.mu.Unlock()
124+
125+ // stop() arrives while the spawn is in flight.
126+ s.stop()
127+
128+ // The in-flight attempt now tries to commit with a stale generation.
129+ err := s.finishStart(gen, nil, []mcp.Tool{{Name: "late"}}, nil)
130+ if err == nil {
131+ t.Fatal("expected error committing a stale start")
132+ }
133+
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ if s.state != stateStopped {
137+ t.Fatalf("state = %s, want stopped", s.state)
138+ }
139+ if s.client != nil {
140+ t.Fatal("stale client must not be installed")
141+ }
142+}
143+
144+func TestCallToolNilClientReturnsErrorNotPanic(t *testing.T) {
145+ s := newManagedServer("bogus", ServerConfig{Transport: "no-such-transport", RequestTimeout: 1, ConnectTimeout: 1}, log.New(io.Discard, "", 0))
146+ _, err := s.callTool(context.Background(), "anything", nil)
147+ if err == nil {
148+ t.Fatal("expected error from unknown transport")
149+ }
150+}
151+
152+func TestPipeStderrKeepsLinesIntactAcrossChunks(t *testing.T) {
153+ var buf bytes.Buffer
154+ s := newManagedServer("srv", ServerConfig{}, log.New(&buf, "", 0))
155+
156+ // iotest.OneByteReader forces 1-byte reads: the old 4096-byte
157+ // chunking code would log every byte as its own "line".
158+ long := strings.Repeat("x", 100)
159+ r := iotest.OneByteReader(strings.NewReader("first " + long + "\nsecond line\n"))
160+ s.pipeStderr(r)
161+
162+ out := buf.String()
163+ if !strings.Contains(out, "[srv:stderr] first "+long+"\n") {
164+ t.Fatalf("first line was split or lost:\n%s", out)
165+ }
166+ if !strings.Contains(out, "[srv:stderr] second line\n") {
167+ t.Fatalf("second line missing:\n%s", out)
168+ }
169+ if got := strings.Count(out, "[srv:stderr]"); got != 2 {
170+ t.Fatalf("logged %d lines, want 2:\n%s", got, out)
171+ }
172+}
173+
174+func TestCompactErrTruncatesOnRuneBoundary(t *testing.T) {
175+ msg := strings.Repeat("ü", 60) // 120 bytes, 60 runes
176+ got := compactErr(msg)
177+ if !utf8.ValidString(got) {
178+ t.Fatalf("truncation split a rune: %q", got)
179+ }
180+ if !strings.HasSuffix(got, "…") {
181+ t.Fatalf("expected ellipsis suffix: %q", got)
182+ }
183+}
184+
185+func TestCompactErrClassifications(t *testing.T) {
186+ cases := []struct{ in, want string }{
187+ {"context deadline exceeded", "timeout"},
188+ {"401 Unauthorized", "auth failed"},
189+ {"exec: \"nope\": executable file not found in $PATH", "command not found"},
190+ {"initialize: something odd", "initialize failed"},
191+ {"list tools: something odd", "tool discovery failed"},
192+ {"", "error"},
193+ }
194+ for _, c := range cases {
195+ if got := compactErr(c.in); got != c.want {
196+ t.Errorf("compactErr(%q) = %q, want %q", c.in, got, c.want)
197+ }
198+ }
199+}
200+
201+func TestMCPResultToZot(t *testing.T) {
202+ if got := mcpResultToZot(nil); len(got.Content) == 0 || got.IsError {
203+ t.Fatalf("nil result: %+v", got)
204+ }
205+
206+ res := &mcp.CallToolResult{
207+ Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "hello"}},
208+ IsError: true,
209+ }
210+ got := mcpResultToZot(res)
211+ if !got.IsError {
212+ t.Fatal("IsError not propagated")
213+ }
214+ if len(got.Content) != 1 {
215+ t.Fatalf("content count = %d, want 1", len(got.Content))
216+ }
217+
218+ empty := mcpResultToZot(&mcp.CallToolResult{})
219+ if len(empty.Content) == 0 {
220+ t.Fatal("empty MCP result must produce placeholder content")
221+ }
222+}
@@ -0,0 +1,222 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "io"
9+ "log"
10+ "strings"
11+ "testing"
12+ "testing/iotest"
13+ "unicode/utf8"
14+
15+ "github.com/mark3labs/mcp-go/mcp"
16+)
17+
18+func TestManagedServerStatusCompact(t *testing.T) {
19+ s := newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
20+
21+ s.state = stateReady
22+ s.tools = []mcp.Tool{{Name: "searchGitHub"}}
23+ if got, want := s.status(), "grep (1 tool)"; got != want {
24+ t.Fatalf("ready status = %q, want %q", got, want)
25+ }
26+
27+ s.state = stateStopped
28+ if got, want := s.status(), "grep (sleeping, 1 tool)"; got != want {
29+ t.Fatalf("stopped status = %q, want %q", got, want)
30+ }
31+
32+ s.state = stateError
33+ s.startErr = errors.New("authorization required")
34+ if got, want := s.status(), "grep (auth failed)"; got != want {
35+ t.Fatalf("error status = %q, want %q", got, want)
36+ }
37+}
38+
39+func TestFormatStatusSummaryCompact(t *testing.T) {
40+ b := &bridge{servers: map[string]*managedServer{}}
41+ b.servers["grep"] = newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
42+ b.servers["grep"].state = stateReady
43+ b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
44+ b.servers["n8n-mcp"] = newManagedServer("n8n-mcp", ServerConfig{}, log.New(io.Discard, "", 0))
45+ b.servers["n8n-mcp"].state = stateReady
46+ b.servers["n8n-mcp"].tools = make([]mcp.Tool, 26)
47+
48+ if got, want := formatStatusSummary(b), "grep (1 tool) | n8n-mcp (26 tools)"; got != want {
49+ t.Fatalf("summary = %q, want %q", got, want)
50+ }
51+ if got, want := b.notifyLevel(), "success"; got != want {
52+ t.Fatalf("notify level = %q, want %q", got, want)
53+ }
54+}
55+
56+func TestManagedServerStopBeforeStartDoesNotPanic(t *testing.T) {
57+ s := newManagedServer("never-started", ServerConfig{}, log.New(io.Discard, "", 0))
58+ s.stop()
59+ if got, want := s.state, stateStopped; got != want {
60+ t.Fatalf("state = %s, want %s", got, want)
61+ }
62+}
63+
64+func TestMCPToolSchemaPreservesExtraFields(t *testing.T) {
65+ schema := mcpToolSchema(mcp.Tool{
66+ Name: "query",
67+ InputSchema: mcp.ToolInputSchema{
68+ Type: "object",
69+ Properties: map[string]any{"sql": map[string]any{"type": "string"}},
70+ Required: []string{"sql"},
71+ Defs: map[string]any{"Thing": map[string]any{"type": "object"}},
72+ AdditionalProperties: false,
73+ },
74+ })
75+
76+ data, err := json.Marshal(schema)
77+ if err != nil {
78+ t.Fatalf("marshal schema: %v", err)
79+ }
80+ var got map[string]any
81+ if err := json.Unmarshal(data, &got); err != nil {
82+ t.Fatalf("unmarshal schema: %v", err)
83+ }
84+ if _, ok := got["$defs"]; !ok {
85+ t.Fatalf("schema did not preserve $defs: %#v", got)
86+ }
87+ if got["additionalProperties"] != false {
88+ t.Fatalf("schema did not preserve additionalProperties=false: %#v", got)
89+ }
90+}
91+
92+func TestCompactErrShortensConnectionFailures(t *testing.T) {
93+ msg := "initialize: transport error: failed to send request: failed to send request"
94+ if got, want := compactErr(msg), "connection failed"; got != want {
95+ t.Fatalf("compactErr = %q, want %q", got, want)
96+ }
97+}
98+
99+func TestNotifyLevelWarnOnPartialFailure(t *testing.T) {
100+ b := &bridge{servers: map[string]*managedServer{}}
101+ b.servers["grep"] = newManagedServer("grep", ServerConfig{}, log.New(io.Discard, "", 0))
102+ b.servers["grep"].state = stateReady
103+ b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
104+ b.servers["broken"] = newManagedServer("broken", ServerConfig{}, log.New(io.Discard, "", 0))
105+ b.servers["broken"].state = stateError
106+ b.servers["broken"].startErr = errors.New("authorization required\nmore detail")
107+
108+ if got, want := formatStatusSummary(b), "broken (auth failed) | grep (1 tool)"; got != want {
109+ t.Fatalf("summary = %q, want %q", got, want)
110+ }
111+ if got, want := b.notifyLevel(), "warn"; got != want {
112+ t.Fatalf("notify level = %q, want %q", got, want)
113+ }
114+}
115+
116+func TestStopDuringStartDiscardsStaleResult(t *testing.T) {
117+ s := newManagedServer("racy", ServerConfig{}, log.New(io.Discard, "", 0))
118+
119+ // Simulate the window inside start(): state is starting, gen recorded.
120+ s.mu.Lock()
121+ s.state = stateStarting
122+ gen := s.gen
123+ s.mu.Unlock()
124+
125+ // stop() arrives while the spawn is in flight.
126+ s.stop()
127+
128+ // The in-flight attempt now tries to commit with a stale generation.
129+ err := s.finishStart(gen, nil, []mcp.Tool{{Name: "late"}}, nil)
130+ if err == nil {
131+ t.Fatal("expected error committing a stale start")
132+ }
133+
134+ s.mu.Lock()
135+ defer s.mu.Unlock()
136+ if s.state != stateStopped {
137+ t.Fatalf("state = %s, want stopped", s.state)
138+ }
139+ if s.client != nil {
140+ t.Fatal("stale client must not be installed")
141+ }
142+}
143+
144+func TestCallToolNilClientReturnsErrorNotPanic(t *testing.T) {
145+ s := newManagedServer("bogus", ServerConfig{Transport: "no-such-transport", RequestTimeout: 1, ConnectTimeout: 1}, log.New(io.Discard, "", 0))
146+ _, err := s.callTool(context.Background(), "anything", nil)
147+ if err == nil {
148+ t.Fatal("expected error from unknown transport")
149+ }
150+}
151+
152+func TestPipeStderrKeepsLinesIntactAcrossChunks(t *testing.T) {
153+ var buf bytes.Buffer
154+ s := newManagedServer("srv", ServerConfig{}, log.New(&buf, "", 0))
155+
156+ // iotest.OneByteReader forces 1-byte reads: the old 4096-byte
157+ // chunking code would log every byte as its own "line".
158+ long := strings.Repeat("x", 100)
159+ r := iotest.OneByteReader(strings.NewReader("first " + long + "\nsecond line\n"))
160+ s.pipeStderr(r)
161+
162+ out := buf.String()
163+ if !strings.Contains(out, "[srv:stderr] first "+long+"\n") {
164+ t.Fatalf("first line was split or lost:\n%s", out)
165+ }
166+ if !strings.Contains(out, "[srv:stderr] second line\n") {
167+ t.Fatalf("second line missing:\n%s", out)
168+ }
169+ if got := strings.Count(out, "[srv:stderr]"); got != 2 {
170+ t.Fatalf("logged %d lines, want 2:\n%s", got, out)
171+ }
172+}
173+
174+func TestCompactErrTruncatesOnRuneBoundary(t *testing.T) {
175+ msg := strings.Repeat("ü", 60) // 120 bytes, 60 runes
176+ got := compactErr(msg)
177+ if !utf8.ValidString(got) {
178+ t.Fatalf("truncation split a rune: %q", got)
179+ }
180+ if !strings.HasSuffix(got, "…") {
181+ t.Fatalf("expected ellipsis suffix: %q", got)
182+ }
183+}
184+
185+func TestCompactErrClassifications(t *testing.T) {
186+ cases := []struct{ in, want string }{
187+ {"context deadline exceeded", "timeout"},
188+ {"401 Unauthorized", "auth failed"},
189+ {"exec: \"nope\": executable file not found in $PATH", "command not found"},
190+ {"initialize: something odd", "initialize failed"},
191+ {"list tools: something odd", "tool discovery failed"},
192+ {"", "error"},
193+ }
194+ for _, c := range cases {
195+ if got := compactErr(c.in); got != c.want {
196+ t.Errorf("compactErr(%q) = %q, want %q", c.in, got, c.want)
197+ }
198+ }
199+}
200+
201+func TestMCPResultToZot(t *testing.T) {
202+ if got := mcpResultToZot(nil); len(got.Content) == 0 || got.IsError {
203+ t.Fatalf("nil result: %+v", got)
204+ }
205+
206+ res := &mcp.CallToolResult{
207+ Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "hello"}},
208+ IsError: true,
209+ }
210+ got := mcpResultToZot(res)
211+ if !got.IsError {
212+ t.Fatal("IsError not propagated")
213+ }
214+ if len(got.Content) != 1 {
215+ t.Fatalf("content count = %d, want 1", len(got.Content))
216+ }
217+
218+ empty := mcpResultToZot(&mcp.CallToolResult{})
219+ if len(empty.Content) == 0 {
220+ t.Fatal("empty MCP result must produce placeholder content")
221+ }
222+}
M packages/agent/ext/ext.go +53 -17
@@ -22,6 +22,11 @@
2222 // Build it, drop the binary + an extension.json next to it under
2323 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
2424 //
25+// Use OnHello when registration or configuration depends on host metadata
26+// such as HostInfo.CWD, Provider, Model, ZotVersion, ExtensionDir, or DataDir.
27+// Run sends hello, waits for hello_ack, runs OnHello, announces registrations,
28+// then sends ready.
29+//
2530 // The same wire format also has reference clients in TypeScript and
2631 // Python under examples/extensions/. Use whichever language fits.
2732 package ext
@@ -257,6 +262,7 @@ type Extension struct {
257262 interceptAssistant AssistantMessageHandler
258263 panelKeys map[string]func(key, text string)
259264 panelCloses map[string]func()
265+ onHello func(HostInfo)
260266
261267 // Caps reported in the hello frame.
262268 caps []string
@@ -309,6 +315,17 @@ func New(name, version string) *Extension {
309315 // Returns the zero value if Run hasn't started yet.
310316 func (e *Extension) Host() HostInfo { return e.host }
311317
318+// OnHello registers a callback that runs after zot acknowledges the
319+// hello frame and before commands/tools are announced. Use it when
320+// registrations depend on host metadata such as CWD or ZotHome-derived
321+// paths. The callback may call Command, Tool, On, and interceptor
322+// registration methods.
323+func (e *Extension) OnHello(fn func(HostInfo)) {
324+ e.mu.Lock()
325+ e.onHello = fn
326+ e.mu.Unlock()
327+}
328+
312329 // Logf writes a line to the extension's stderr, which zot captures to
313330 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314331 // you print to stdout would corrupt the JSON wire protocol.
@@ -451,9 +468,12 @@ func (e *Extension) Notify(level, message string) {
451468 // Run starts the protocol loop. Blocks until stdin closes (zot has
452469 // shut us down). Returns the first fatal error, or nil on clean exit.
453470 func (e *Extension) Run() error {
454- // Send hello, then re-announce all commands (covers Command calls
455- // made before Run, and is also fine for those made after via
456- // Command()'s direct send).
471+ scanner := bufio.NewScanner(e.in)
472+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
473+
474+ // Send hello first. zot answers with hello_ack immediately; read it
475+ // synchronously so extensions can use HostInfo while registering
476+ // commands/tools before the ready frame.
457477 if err := e.send(extproto.HelloFromExt{
458478 Type: "hello",
459479 Name: e.name,
@@ -462,6 +482,35 @@ func (e *Extension) Run() error {
462482 }); err != nil {
463483 return err
464484 }
485+ if !scanner.Scan() {
486+ return scanner.Err()
487+ }
488+ var ack extproto.HelloAckFromHost
489+ if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil {
490+ return fmt.Errorf("parse hello_ack: %w", err)
491+ }
492+ if ack.Type != "hello_ack" {
493+ return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
494+ }
495+ e.host = HostInfo{
496+ ProtocolVersion: ack.ProtocolVersion,
497+ ZotVersion: ack.ZotVersion,
498+ Provider: ack.Provider,
499+ Model: ack.Model,
500+ CWD: ack.CWD,
501+ ExtensionDir: ack.ExtensionDir,
502+ DataDir: ack.DataDir,
503+ }
504+ e.mu.Lock()
505+ onHello := e.onHello
506+ e.mu.Unlock()
507+ if onHello != nil {
508+ onHello(e.host)
509+ }
510+
511+ // Re-announce all commands (covers Command calls made before Run and
512+ // from OnHello, and is also fine for those made after via Command()'s
513+ // direct send).
465514 e.mu.Lock()
466515 descs := append([]descTuple(nil), e.descriptions...)
467516 toolDefs := append([]toolDef(nil), e.toolDefs...)
@@ -508,8 +557,6 @@ func (e *Extension) Run() error {
508557 // land in time for the typical use case.
509558 _ = e.send(extproto.ReadyFromExt{Type: "ready"})
510559
511- scanner := bufio.NewScanner(e.in)
512- scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
513560 for scanner.Scan() {
514561 line := scanner.Bytes()
515562 var frame extproto.Frame
@@ -519,18 +566,7 @@ func (e *Extension) Run() error {
519566 }
520567 switch frame.Type {
521568 case "hello_ack":
522- var ack extproto.HelloAckFromHost
523- if err := json.Unmarshal(line, &ack); err == nil {
524- e.host = HostInfo{
525- ProtocolVersion: ack.ProtocolVersion,
526- ZotVersion: ack.ZotVersion,
527- Provider: ack.Provider,
528- Model: ack.Model,
529- CWD: ack.CWD,
530- ExtensionDir: ack.ExtensionDir,
531- DataDir: ack.DataDir,
532- }
533- }
569+ // Already handled before registrations; ignore duplicates.
534570 case "command_invoked":
535571 var ci extproto.CommandInvokedFromHost
536572 if err := json.Unmarshal(line, &ci); err != nil {
@@ -22,6 +22,11 @@
22 // Build it, drop the binary + an extension.json next to it under22 // Build it, drop the binary + an extension.json next to it under
23 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.23 // `$ZOT_HOME/extensions/hello/`, and zot picks it up on next launch.
24 //24 //
25+// Use OnHello when registration or configuration depends on host metadata
26+// such as HostInfo.CWD, Provider, Model, ZotVersion, ExtensionDir, or DataDir.
27+// Run sends hello, waits for hello_ack, runs OnHello, announces registrations,
28+// then sends ready.
29+//
25 // The same wire format also has reference clients in TypeScript and30 // The same wire format also has reference clients in TypeScript and
26 // Python under examples/extensions/. Use whichever language fits.31 // Python under examples/extensions/. Use whichever language fits.
27 package ext32 package ext
@@ -257,6 +262,7 @@ type Extension struct {
257 interceptAssistant AssistantMessageHandler262 interceptAssistant AssistantMessageHandler
258 panelKeys map[string]func(key, text string)263 panelKeys map[string]func(key, text string)
259 panelCloses map[string]func()264 panelCloses map[string]func()
265+ onHello func(HostInfo)
260 266
261 // Caps reported in the hello frame.267 // Caps reported in the hello frame.
262 caps []string268 caps []string
@@ -309,6 +315,17 @@ func New(name, version string) *Extension {
309 // Returns the zero value if Run hasn't started yet.315 // Returns the zero value if Run hasn't started yet.
310 func (e *Extension) Host() HostInfo { return e.host }316 func (e *Extension) Host() HostInfo { return e.host }
311 317
318+// OnHello registers a callback that runs after zot acknowledges the
319+// hello frame and before commands/tools are announced. Use it when
320+// registrations depend on host metadata such as CWD or ZotHome-derived
321+// paths. The callback may call Command, Tool, On, and interceptor
322+// registration methods.
323+func (e *Extension) OnHello(fn func(HostInfo)) {
324+ e.mu.Lock()
325+ e.onHello = fn
326+ e.mu.Unlock()
327+}
328+
312 // Logf writes a line to the extension's stderr, which zot captures to329 // Logf writes a line to the extension's stderr, which zot captures to
313 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything330 // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything
314 // you print to stdout would corrupt the JSON wire protocol.331 // you print to stdout would corrupt the JSON wire protocol.
@@ -451,9 +468,12 @@ func (e *Extension) Notify(level, message string) {
451 // Run starts the protocol loop. Blocks until stdin closes (zot has468 // Run starts the protocol loop. Blocks until stdin closes (zot has
452 // shut us down). Returns the first fatal error, or nil on clean exit.469 // shut us down). Returns the first fatal error, or nil on clean exit.
453 func (e *Extension) Run() error {470 func (e *Extension) Run() error {
454- // Send hello, then re-announce all commands (covers Command calls471+ scanner := bufio.NewScanner(e.in)
455- // made before Run, and is also fine for those made after via472+ scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
456- // Command()'s direct send).473+
474+ // Send hello first. zot answers with hello_ack immediately; read it
475+ // synchronously so extensions can use HostInfo while registering
476+ // commands/tools before the ready frame.
457 if err := e.send(extproto.HelloFromExt{477 if err := e.send(extproto.HelloFromExt{
458 Type: "hello",478 Type: "hello",
459 Name: e.name,479 Name: e.name,
@@ -462,6 +482,35 @@ func (e *Extension) Run() error {
462 }); err != nil {482 }); err != nil {
463 return err483 return err
464 }484 }
485+ if !scanner.Scan() {
486+ return scanner.Err()
487+ }
488+ var ack extproto.HelloAckFromHost
489+ if err := json.Unmarshal(scanner.Bytes(), &ack); err != nil {
490+ return fmt.Errorf("parse hello_ack: %w", err)
491+ }
492+ if ack.Type != "hello_ack" {
493+ return fmt.Errorf("first host frame must be hello_ack (got %q)", ack.Type)
494+ }
495+ e.host = HostInfo{
496+ ProtocolVersion: ack.ProtocolVersion,
497+ ZotVersion: ack.ZotVersion,
498+ Provider: ack.Provider,
499+ Model: ack.Model,
500+ CWD: ack.CWD,
501+ ExtensionDir: ack.ExtensionDir,
502+ DataDir: ack.DataDir,
503+ }
504+ e.mu.Lock()
505+ onHello := e.onHello
506+ e.mu.Unlock()
507+ if onHello != nil {
508+ onHello(e.host)
509+ }
510+
511+ // Re-announce all commands (covers Command calls made before Run and
512+ // from OnHello, and is also fine for those made after via Command()'s
513+ // direct send).
465 e.mu.Lock()514 e.mu.Lock()
466 descs := append([]descTuple(nil), e.descriptions...)515 descs := append([]descTuple(nil), e.descriptions...)
467 toolDefs := append([]toolDef(nil), e.toolDefs...)516 toolDefs := append([]toolDef(nil), e.toolDefs...)
@@ -508,8 +557,6 @@ func (e *Extension) Run() error {
508 // land in time for the typical use case.557 // land in time for the typical use case.
509 _ = e.send(extproto.ReadyFromExt{Type: "ready"})558 _ = e.send(extproto.ReadyFromExt{Type: "ready"})
510 559
511- scanner := bufio.NewScanner(e.in)
512- scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
513 for scanner.Scan() {560 for scanner.Scan() {
514 line := scanner.Bytes()561 line := scanner.Bytes()
515 var frame extproto.Frame562 var frame extproto.Frame
@@ -519,18 +566,7 @@ func (e *Extension) Run() error {
519 }566 }
520 switch frame.Type {567 switch frame.Type {
521 case "hello_ack":568 case "hello_ack":
522- var ack extproto.HelloAckFromHost569+ // Already handled before registrations; ignore duplicates.
523- if err := json.Unmarshal(line, &ack); err == nil {
524- e.host = HostInfo{
525- ProtocolVersion: ack.ProtocolVersion,
526- ZotVersion: ack.ZotVersion,
527- Provider: ack.Provider,
528- Model: ack.Model,
529- CWD: ack.CWD,
530- ExtensionDir: ack.ExtensionDir,
531- DataDir: ack.DataDir,
532- }
533- }
534 case "command_invoked":570 case "command_invoked":
535 var ci extproto.CommandInvokedFromHost571 var ci extproto.CommandInvokedFromHost
536 if err := json.Unmarshal(line, &ci); err != nil {572 if err := json.Unmarshal(line, &ci); err != nil {
M packages/agent/ext/ext_test.go +52 -0
@@ -137,6 +137,58 @@ func (h *extHarness) handshake(t *testing.T) {
137137
138138 // ---------- tests ----------
139139
140+// TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
141+// host metadata before announcing their initial registrations.
142+func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
143+ h := newHarness("cwd-ext")
144+
145+ h.ext.OnHello(func(host HostInfo) {
146+ h.ext.Command("cwd", "show cwd", func(args string) Response {
147+ return Display(host.CWD)
148+ })
149+ })
150+
151+ go h.ext.Run()
152+
153+ f := h.next(t)
154+ if f.hdr.Type != "hello" {
155+ t.Fatalf("expected hello, got %q", f.hdr.Type)
156+ }
157+ h.sendToExt(t, extproto.HelloAckFromHost{
158+ Type: "hello_ack",
159+ ProtocolVersion: extproto.ProtocolVersion,
160+ ZotVersion: "0.0.0-test",
161+ Provider: "anthropic",
162+ Model: "claude-test",
163+ CWD: "/tmp/project",
164+ })
165+
166+ var sawCommand bool
167+ for {
168+ f := h.next(t)
169+ if f.hdr.Type == "register_command" {
170+ var rc extproto.RegisterCommandFromExt
171+ if err := json.Unmarshal(f.raw, &rc); err != nil {
172+ t.Fatalf("unmarshal register_command: %v", err)
173+ }
174+ if rc.Name == "cwd" {
175+ sawCommand = true
176+ }
177+ }
178+ if f.hdr.Type == "ready" {
179+ break
180+ }
181+ }
182+ if !sawCommand {
183+ t.Fatal("OnHello-registered command was not announced before ready")
184+ }
185+ if got := h.ext.Host().CWD; got != "/tmp/project" {
186+ t.Fatalf("Host().CWD = %q, want /tmp/project", got)
187+ }
188+
189+ h.hostW.Close()
190+}
191+
140192 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141193 // well-formed open_panel frame with the correct PanelSpec fields.
142194 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
@@ -137,6 +137,58 @@ func (h *extHarness) handshake(t *testing.T) {
137 137
138 // ---------- tests ----------138 // ---------- tests ----------
139 139
140+// TestOnHelloCanRegisterUsingHostInfo checks that extensions can use
141+// host metadata before announcing their initial registrations.
142+func TestOnHelloCanRegisterUsingHostInfo(t *testing.T) {
143+ h := newHarness("cwd-ext")
144+
145+ h.ext.OnHello(func(host HostInfo) {
146+ h.ext.Command("cwd", "show cwd", func(args string) Response {
147+ return Display(host.CWD)
148+ })
149+ })
150+
151+ go h.ext.Run()
152+
153+ f := h.next(t)
154+ if f.hdr.Type != "hello" {
155+ t.Fatalf("expected hello, got %q", f.hdr.Type)
156+ }
157+ h.sendToExt(t, extproto.HelloAckFromHost{
158+ Type: "hello_ack",
159+ ProtocolVersion: extproto.ProtocolVersion,
160+ ZotVersion: "0.0.0-test",
161+ Provider: "anthropic",
162+ Model: "claude-test",
163+ CWD: "/tmp/project",
164+ })
165+
166+ var sawCommand bool
167+ for {
168+ f := h.next(t)
169+ if f.hdr.Type == "register_command" {
170+ var rc extproto.RegisterCommandFromExt
171+ if err := json.Unmarshal(f.raw, &rc); err != nil {
172+ t.Fatalf("unmarshal register_command: %v", err)
173+ }
174+ if rc.Name == "cwd" {
175+ sawCommand = true
176+ }
177+ }
178+ if f.hdr.Type == "ready" {
179+ break
180+ }
181+ }
182+ if !sawCommand {
183+ t.Fatal("OnHello-registered command was not announced before ready")
184+ }
185+ if got := h.ext.Host().CWD; got != "/tmp/project" {
186+ t.Fatalf("Host().CWD = %q, want /tmp/project", got)
187+ }
188+
189+ h.hostW.Close()
190+}
191+
140 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a192 // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a
141 // well-formed open_panel frame with the correct PanelSpec fields.193 // well-formed open_panel frame with the correct PanelSpec fields.
142 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {194 func TestOpenPanelEmitsCorrectFrame(t *testing.T) {
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +40 -16
@@ -106,10 +106,11 @@ zot replies with `hello_ack`:
106106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107107 ```
108108
109-### Registration (immediately after hello)
109+### Registration (after hello_ack)
110110
111-Send registration frames in any order, then a single `ready`
112-sentinel so zot can finalize the agent's tool registry:
111+The canonical startup order is `hello`, wait for `hello_ack`, send
112+registration frames in any order, then send a single `ready` sentinel
113+so zot can finalize the agent's tool registry:
113114
114115 ```json
115116 {"type":"register_command","name":"weather","description":"current weather"}
@@ -121,7 +122,11 @@ sentinel so zot can finalize the agent's tool registry:
121122
122123 If you don't send `ready`, zot's idle watchdog auto-treats you as
123124 ready after 250ms of no frames, but always send it explicitly when
124-you can — newer extensions on faster hosts shave that 250ms off.
125+you can. Newer extensions on faster hosts shave that 250ms off.
126+
127+Use `hello_ack.cwd` for the user's project directory. The extension
128+process itself runs from the extension directory, so do not use
129+`os.Getwd()` or `process.cwd()` when you need the project path.
125130
126131 ### Runtime frames
127132
@@ -171,9 +176,10 @@ never stalls the agent.
171176 ## Important rules
172177
173178 - **stdout is reserved for the protocol.** Anything you print to
174- stdout that isn't a JSON frame breaks the wire. Use stderr for
175- logs / debug output (zot captures stderr to
176- `$ZOT_HOME/logs/ext-<name>.log`).
179+ stdout that isn't a JSON frame breaks the wire. The first stdout
180+ frame must be `hello`; do not send `notify`, logs, or registration
181+ frames before that handshake starts. Use stderr for logs / debug
182+ output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
177183 - **One JSON object per line.** No multi-line JSON. Always end
178184 every frame with `\n`.
179185 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -212,6 +218,15 @@ func main() {
212218 return ext.TextResult(in.City + ": sunny, 21°C (fake)")
213219 })
214220
221+ // Optional: register project-specific commands after hello_ack.
222+ e.OnHello(func(host ext.HostInfo) {
223+ if host.CWD != "" {
224+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
225+ return ext.Display(host.CWD)
226+ })
227+ }
228+ })
229+
215230 if err := e.Run(); err != nil {
216231 e.Logf("fatal: %v", err)
217232 }
@@ -220,6 +235,11 @@ func main() {
220235
221236 Build: `go build -o weather .`
222237
238+`OnHello` is optional. Use it when configuration or registrations need
239+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
240+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
241+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
242+
223243 `extension.json`:
224244 ```json
225245 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
@@ -243,16 +263,18 @@ function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
243263
244264 send({ type: "hello", name: "scratchpad", version: "1.0.0",
245265 capabilities: ["commands", "tools"] });
246-send({ type: "register_command", name: "note", description: "append a note" });
247-send({ type: "register_tool", name: "read_notes",
248- description: "Read the user's scratchpad notes.",
249- schema: { type: "object", properties: {} } });
250-send({ type: "ready" });
251266
252267 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
253268 rl.on("line", (line) => {
254269 const f = JSON.parse(line);
255- if (f.type === "command_invoked" && f.name === "note") {
270+ if (f.type === "hello_ack") {
271+ // f.cwd is the user's project directory.
272+ send({ type: "register_command", name: "note", description: "append a note" });
273+ send({ type: "register_tool", name: "read_notes",
274+ description: "Read the user's scratchpad notes.",
275+ schema: { type: "object", properties: {} } });
276+ send({ type: "ready" });
277+ } else if (f.type === "command_invoked" && f.name === "note") {
256278 send({ type: "command_response", id: f.id, action: "display",
257279 display: `noted: ${f.args}` });
258280 } else if (f.type === "tool_call" && f.name === "read_notes") {
@@ -282,12 +304,14 @@ import json, sys
282304 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
283305
284306 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
285-emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
286-emit({"type": "ready"})
287307
288308 for line in sys.stdin:
289309 msg = json.loads(line)
290- if msg["type"] == "command_invoked":
310+ if msg["type"] == "hello_ack":
311+ # msg["cwd"] is the user's project directory.
312+ emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
313+ emit({"type": "ready"})
314+ elif msg["type"] == "command_invoked":
291315 emit({"type": "command_response", "id": msg["id"],
292316 "action": "prompt", "prompt": "Say hi briefly."})
293317 elif msg["type"] == "shutdown":
@@ -106,10 +106,11 @@ zot replies with `hello_ack`:
106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}106 "provider":"anthropic","model":"claude-opus-4-7","cwd":"/path/to/project"}
107 ```107 ```
108 108
109-### Registration (immediately after hello)109+### Registration (after hello_ack)
110 110
111-Send registration frames in any order, then a single `ready`111+The canonical startup order is `hello`, wait for `hello_ack`, send
112-sentinel so zot can finalize the agent's tool registry:112+registration frames in any order, then send a single `ready` sentinel
113+so zot can finalize the agent's tool registry:
113 114
114 ```json115 ```json
115 {"type":"register_command","name":"weather","description":"current weather"}116 {"type":"register_command","name":"weather","description":"current weather"}
@@ -121,7 +122,11 @@ sentinel so zot can finalize the agent's tool registry:
121 122
122 If you don't send `ready`, zot's idle watchdog auto-treats you as123 If you don't send `ready`, zot's idle watchdog auto-treats you as
123 ready after 250ms of no frames, but always send it explicitly when124 ready after 250ms of no frames, but always send it explicitly when
124-you can — newer extensions on faster hosts shave that 250ms off.125+you can. Newer extensions on faster hosts shave that 250ms off.
126+
127+Use `hello_ack.cwd` for the user's project directory. The extension
128+process itself runs from the extension directory, so do not use
129+`os.Getwd()` or `process.cwd()` when you need the project path.
125 130
126 ### Runtime frames131 ### Runtime frames
127 132
@@ -171,9 +176,10 @@ never stalls the agent.
171 ## Important rules176 ## Important rules
172 177
173 - **stdout is reserved for the protocol.** Anything you print to178 - **stdout is reserved for the protocol.** Anything you print to
174- stdout that isn't a JSON frame breaks the wire. Use stderr for179+ stdout that isn't a JSON frame breaks the wire. The first stdout
175- logs / debug output (zot captures stderr to180+ frame must be `hello`; do not send `notify`, logs, or registration
176- `$ZOT_HOME/logs/ext-<name>.log`).181+ frames before that handshake starts. Use stderr for logs / debug
182+ output (zot captures stderr to `$ZOT_HOME/logs/ext-<name>.log`).
177 - **One JSON object per line.** No multi-line JSON. Always end183 - **One JSON object per line.** No multi-line JSON. Always end
178 every frame with `\n`.184 every frame with `\n`.
179 - **Flush after writing.** Most stdout writes are line-buffered when185 - **Flush after writing.** Most stdout writes are line-buffered when
@@ -212,6 +218,15 @@ func main() {
212 return ext.TextResult(in.City + ": sunny, 21°C (fake)")218 return ext.TextResult(in.City + ": sunny, 21°C (fake)")
213 })219 })
214 220
221+ // Optional: register project-specific commands after hello_ack.
222+ e.OnHello(func(host ext.HostInfo) {
223+ if host.CWD != "" {
224+ e.Command("cwd", "show the current project directory", func(args string) ext.Response {
225+ return ext.Display(host.CWD)
226+ })
227+ }
228+ })
229+
215 if err := e.Run(); err != nil {230 if err := e.Run(); err != nil {
216 e.Logf("fatal: %v", err)231 e.Logf("fatal: %v", err)
217 }232 }
@@ -220,6 +235,11 @@ func main() {
220 235
221 Build: `go build -o weather .`236 Build: `go build -o weather .`
222 237
238+`OnHello` is optional. Use it when configuration or registrations need
239+host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`,
240+`ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for
241+`hello_ack`, runs `OnHello`, announces registrations, then sends `ready`.
242+
223 `extension.json`:243 `extension.json`:
224 ```json244 ```json
225 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}245 {"name":"weather","version":"1.0.0","exec":"./weather","language":"go","enabled":true}
@@ -243,16 +263,18 @@ function log(s: string) { stderr.write(`[scratchpad] ${s}\n`); }
243 263
244 send({ type: "hello", name: "scratchpad", version: "1.0.0",264 send({ type: "hello", name: "scratchpad", version: "1.0.0",
245 capabilities: ["commands", "tools"] });265 capabilities: ["commands", "tools"] });
246-send({ type: "register_command", name: "note", description: "append a note" });
247-send({ type: "register_tool", name: "read_notes",
248- description: "Read the user's scratchpad notes.",
249- schema: { type: "object", properties: {} } });
250-send({ type: "ready" });
251 266
252 const rl = createInterface({ input: stdin, crlfDelay: Infinity });267 const rl = createInterface({ input: stdin, crlfDelay: Infinity });
253 rl.on("line", (line) => {268 rl.on("line", (line) => {
254 const f = JSON.parse(line);269 const f = JSON.parse(line);
255- if (f.type === "command_invoked" && f.name === "note") {270+ if (f.type === "hello_ack") {
271+ // f.cwd is the user's project directory.
272+ send({ type: "register_command", name: "note", description: "append a note" });
273+ send({ type: "register_tool", name: "read_notes",
274+ description: "Read the user's scratchpad notes.",
275+ schema: { type: "object", properties: {} } });
276+ send({ type: "ready" });
277+ } else if (f.type === "command_invoked" && f.name === "note") {
256 send({ type: "command_response", id: f.id, action: "display",278 send({ type: "command_response", id: f.id, action: "display",
257 display: `noted: ${f.args}` });279 display: `noted: ${f.args}` });
258 } else if (f.type === "tool_call" && f.name === "read_notes") {280 } else if (f.type === "tool_call" && f.name === "read_notes") {
@@ -282,12 +304,14 @@ import json, sys
282 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()304 def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()
283 305
284 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})306 emit({"type": "hello", "name": "hello-py", "version": "1.0.0", "capabilities": ["commands"]})
285-emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
286-emit({"type": "ready"})
287 307
288 for line in sys.stdin:308 for line in sys.stdin:
289 msg = json.loads(line)309 msg = json.loads(line)
290- if msg["type"] == "command_invoked":310+ if msg["type"] == "hello_ack":
311+ # msg["cwd"] is the user's project directory.
312+ emit({"type": "register_command", "name": "hellopy", "description": "say hi (python)"})
313+ emit({"type": "ready"})
314+ elif msg["type"] == "command_invoked":
291 emit({"type": "command_response", "id": msg["id"],315 emit({"type": "command_response", "id": msg["id"],
292 "action": "prompt", "prompt": "Say hi briefly."})316 "action": "prompt", "prompt": "Say hi briefly."})
293 elif msg["type"] == "shutdown":317 elif msg["type"] == "shutdown":

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