fix: harden MCP bridge startup
- commit
- 7f35cae72ad59db134e66a54158385e324b369d5
- author
- patriceckhart · · 2026-07-02 05:46 UTC
- parent
- 04e3270
- signature
- unsigned
6 files · +256 -111
M docs/extensions.md +45 -21
| @@ -25,20 +25,22 @@ no SDK required: | |||
| 25 | 25 | ```python | |
| 26 | 26 | #!/usr/bin/env python3 | |
| 27 | 27 | # $ZOT_HOME/extensions/hello-py/hello.py | |
| 28 | - | import json, sys, threading | |
| 28 | + | import json, sys | |
| 29 | 29 | ||
| 30 | 30 | def emit(obj): | |
| 31 | 31 | sys.stdout.write(json.dumps(obj) + "\n") | |
| 32 | 32 | sys.stdout.flush() | |
| 33 | 33 | ||
| 34 | 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 | 36 | for line in sys.stdin: | |
| 38 | 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 | 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 | 44 | elif msg["type"] == "shutdown": | |
| 43 | 45 | emit({"type":"shutdown_ack"}) | |
| 44 | 46 | break | |
| @@ -135,19 +137,21 @@ manifest tells zot how to launch it: | |||
| 135 | 137 | 2. **Spawn**: enabled extensions are launched as subprocesses. stderr | |
| 136 | 138 | redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per | |
| 137 | 139 | 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. | |
| 151 | 155 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | |
| 152 | 156 | 2s for the extension to send `shutdown_ack`. Holdouts are | |
| 153 | 157 | SIGTERM'd, then SIGKILL'd. | |
| @@ -160,7 +164,9 @@ restarted. | |||
| 160 | 164 | ||
| 161 | 165 | All frames are one JSON object per line. Top-level `type` is the | |
| 162 | 166 | 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 | 171 | ### Extension → host | |
| 166 | 172 | ||
| @@ -370,9 +376,13 @@ Sent in response to `shutdown`. Extension should exit promptly after. | |||
| 370 | 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 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. | |
| 376 | 386 | `extension_dir` / `data_dir` are where the extension should persist | |
| 377 | 387 | its own state (for example `todos.json`, cached metadata, or auth | |
| 378 | 388 | tokens scoped to that extension). | |
| @@ -526,6 +536,15 @@ func main() { | |||
| 526 | 536 | return ext.TextResult(in.City + ": sunny") | |
| 527 | 537 | }) | |
| 528 | 538 | ||
| 539 | + | // Optional: register project-specific commands after hello_ack. | |
| 540 | + | e.OnHello(func(host ext.HostInfo) { | |
| 541 | + | if host.CWD != "" { | |
| 542 | + | e.Command("cwd", "show the current project directory", func(args string) ext.Response { | |
| 543 | + | return ext.Display(host.CWD) | |
| 544 | + | }) | |
| 545 | + | } | |
| 546 | + | }) | |
| 547 | + | ||
| 529 | 548 | e.Run() | |
| 530 | 549 | } | |
| 531 | 550 | ``` | |
| @@ -533,6 +552,11 @@ func main() { | |||
| 533 | 552 | Build with `go build -o hello .`, drop the binary + an `extension.json` | |
| 534 | 553 | into `$ZOT_HOME/extensions/hello/`. | |
| 535 | 554 | ||
| 555 | + | `OnHello` is optional. Use it when configuration or registrations need | |
| 556 | + | host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |
| 557 | + | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | |
| 558 | + | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | |
| 559 | + | ||
| 536 | 560 | The SDK has four interceptor hooks, all optional: | |
| 537 | 561 | ||
| 538 | 562 | ```go | |
| @@ -25,20 +25,22 @@ no SDK required: | |||||
| 25 | ```python | 25 | ```python | ||
| 26 | #!/usr/bin/env python3 | 26 | #!/usr/bin/env python3 | ||
| 27 | # $ZOT_HOME/extensions/hello-py/hello.py | 27 | # $ZOT_HOME/extensions/hello-py/hello.py | ||
| 28 | - | import json, sys, threading | 28 | + | 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 | break | 46 | break | ||
| @@ -135,19 +137,21 @@ manifest tells zot how to launch it: | |||||
| 135 | 2. **Spawn**: enabled extensions are launched as subprocesses. stderr | 137 | 2. **Spawn**: enabled extensions are launched as subprocesses. stderr | ||
| 136 | redirects to `$ZOT_HOME/logs/ext-<name>.log` (one file per | 138 | 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; zot | 140 | + | 3. **Hello handshake**: the extension's first stdout frame must be |
| 139 | - | replies with `hello_ack` containing the protocol version, the | 141 | + | `hello`; zot replies with `hello_ack` containing the protocol |
| 140 | - | active provider/model/cwd, and the extension's own data directory | 142 | + | 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 by | 145 | + | `register_command`, `register_tool`, and subscription frames, then |
| 144 | - | a previously-loaded extension is silently shadowed (logged in the | 146 | + | 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 the | 148 | + | (logged in the extension's own log file). |
| 147 | - | user runs a registered command; the extension responds with | 149 | + | 5. **Runtime**: after `ready`, zot dispatches `command_invoked` frames |
| 148 | - | `command_response`. Extensions can also push `notify` frames at | 150 | + | 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 to | 155 | 6. **Shutdown**: when zot exits, it sends `shutdown` and waits up to | ||
| 152 | 2s for the extension to send `shutdown_ack`. Holdouts are | 156 | 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 the | 165 | All frames are one JSON object per line. Top-level `type` is the | ||
| 162 | discriminator. Optional `id` correlates request frames with their | 166 | 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 → host | 171 | ### 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 to | 379 | + | Sent immediately after `hello`. Wait for this frame before sending |
| 374 | - | decide which commands to register (e.g. only register a Python tool | 380 | + | 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 persist | 386 | `extension_dir` / `data_dir` are where the extension should persist | ||
| 377 | its own state (for example `todos.json`, cached metadata, or auth | 387 | its own state (for example `todos.json`, cached metadata, or auth | ||
| 378 | tokens scoped to that extension). | 388 | tokens scoped to that extension). | ||
| @@ -526,6 +536,15 @@ func main() { | |||||
| 526 | return ext.TextResult(in.City + ": sunny") | 536 | return ext.TextResult(in.City + ": sunny") | ||
| 527 | }) | 537 | }) | ||
| 528 | 538 | ||||
| 539 | + | // Optional: register project-specific commands after hello_ack. | |||
| 540 | + | e.OnHello(func(host ext.HostInfo) { | |||
| 541 | + | if host.CWD != "" { | |||
| 542 | + | e.Command("cwd", "show the current project directory", func(args string) ext.Response { | |||
| 543 | + | return ext.Display(host.CWD) | |||
| 544 | + | }) | |||
| 545 | + | } | |||
| 546 | + | }) | |||
| 547 | + | ||||
| 529 | e.Run() | 548 | e.Run() | ||
| 530 | } | 549 | } | ||
| 531 | ``` | 550 | ``` | ||
| @@ -533,6 +552,11 @@ func main() { | |||||
| 533 | Build with `go build -o hello .`, drop the binary + an `extension.json` | 552 | Build with `go build -o hello .`, drop the binary + an `extension.json` | ||
| 534 | into `$ZOT_HOME/extensions/hello/`. | 553 | into `$ZOT_HOME/extensions/hello/`. | ||
| 535 | 554 | ||||
| 555 | + | `OnHello` is optional. Use it when configuration or registrations need | |||
| 556 | + | host metadata such as `HostInfo.CWD`, `Provider`, `Model`, `ZotVersion`, | |||
| 557 | + | `ExtensionDir`, or `DataDir`. The SDK sends `hello`, waits for | |||
| 558 | + | `hello_ack`, runs `OnHello`, announces registrations, then sends `ready`. | |||
| 559 | + | ||||
| 536 | The SDK has four interceptor hooks, all optional: | 560 | The SDK has four interceptor hooks, all optional: | ||
| 537 | 561 | ||||
| 538 | ```go | 562 | ```go | ||
M examples/extensions/mcp-bridge/README.md +18 -16
| @@ -7,7 +7,7 @@ This extension reads MCP server configurations from standard locations (same for | |||
| 7 | 7 | ## Features | |
| 8 | 8 | ||
| 9 | 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | |
| 10 | - | - **Smart lazy loading** — servers spawn on startup to discover tools, then auto-sleep after idle time | |
| 10 | + | - **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time | |
| 11 | 11 | - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically | |
| 12 | 12 | - **Multi-transport** — stdio, streamable-http, and SSE transports | |
| 13 | 13 | - **Multi-server** — connect to any number of MCP servers simultaneously | |
| @@ -53,7 +53,7 @@ This extension reads MCP server configurations from standard locations (same for | |||
| 53 | 53 | zot ext install . | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 56 | - | 4. **Restart zot** — your MCP tools are ready! | |
| 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 | 57 | ||
| 58 | 58 | ## Configuration | |
| 59 | 59 | ||
| @@ -168,12 +168,13 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||
| 168 | 168 | └──────────────────────────────────────────────────────────────┘ | |
| 169 | 169 | ``` | |
| 170 | 170 | ||
| 171 | - | 1. **Startup**: mcp-bridge reads config, spawns all MCP servers | |
| 172 | - | 2. **Discovery**: calls `tools/list` on each server, registers tools with zot | |
| 173 | - | 3. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`) | |
| 174 | - | 4. **Idle timeout**: servers not used for 5 minutes are automatically stopped | |
| 175 | - | 5. **Auto-respawn**: calling a tool on a stopped server wakes it up | |
| 176 | - | 6. **Routing**: tool calls are forwarded to the appropriate MCP server | |
| 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 | |
| 177 | 178 | ||
| 178 | 179 | ## Slash Commands | |
| 179 | 180 | ||
| @@ -210,16 +211,17 @@ Server and tool names are sanitized (non-alphanumeric characters become `_`). | |||
| 210 | 211 | ||
| 211 | 212 | The bridge uses a "smart lazy" strategy: | |
| 212 | 213 | ||
| 213 | - | 1. **On startup**: all servers spawn, tools are discovered and registered | |
| 214 | - | 2. **During use**: servers stay running for fast tool calls | |
| 215 | - | 3. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU) | |
| 216 | - | 4. **On next tool call**: the server is respawned automatically (~1-3s delay) | |
| 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) | |
| 217 | 219 | ||
| 218 | 220 | This gives you: | |
| 219 | - | - ✅ All tools visible to the LLM immediately | |
| 220 | - | - ✅ Fast tool calls when actively working | |
| 221 | - | - ✅ Memory freed when not using MCP tools | |
| 222 | - | - ✅ No manual server management needed | |
| 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 | |
| 223 | 225 | ||
| 224 | 226 | ## Troubleshooting | |
| 225 | 227 | ||
| @@ -7,7 +7,7 @@ This extension reads MCP server configurations from standard locations (same for | |||||
| 7 | ## Features | 7 | ## Features | ||
| 8 | 8 | ||||
| 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | ||
| 10 | - | - **Smart lazy loading** — servers spawn on startup to discover tools, then auto-sleep after idle time | 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 | 11 | - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically | ||
| 12 | - **Multi-transport** — stdio, streamable-http, and SSE transports | 12 | - **Multi-transport** — stdio, streamable-http, and SSE transports | ||
| 13 | - **Multi-server** — connect to any number of MCP servers simultaneously | 13 | - **Multi-server** — connect to any number of MCP servers simultaneously | ||
| @@ -53,7 +53,7 @@ This extension reads MCP server configurations from standard locations (same for | |||||
| 53 | zot ext install . | 53 | zot ext install . | ||
| 54 | ``` | 54 | ``` | ||
| 55 | 55 | ||||
| 56 | - | 4. **Restart zot** — your MCP tools are ready! | 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 | 57 | ||||
| 58 | ## Configuration | 58 | ## Configuration | ||
| 59 | 59 | ||||
| @@ -168,12 +168,13 @@ Standard MCP config — same as Claude Desktop, with zot-specific extensions: | |||||
| 168 | └──────────────────────────────────────────────────────────────┘ | 168 | └──────────────────────────────────────────────────────────────┘ | ||
| 169 | ``` | 169 | ``` | ||
| 170 | 170 | ||||
| 171 | - | 1. **Startup**: mcp-bridge reads config, spawns all MCP servers | 171 | + | 1. **Startup**: mcp-bridge reads config and registers tools from `mcp-tools-cache.json` |
| 172 | - | 2. **Discovery**: calls `tools/list` on each server, registers tools with zot | 172 | + | 2. **Background refresh**: starts configured MCP servers, calls `tools/list`, and updates the cache when definitions change |
| 173 | - | 3. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`) | 173 | + | 3. **Reload**: if the cache changed, run `/reload-ext` once so zot rebuilds the tool registry with the new definitions |
| 174 | - | 4. **Idle timeout**: servers not used for 5 minutes are automatically stopped | 174 | + | 4. **Naming**: tools appear as `mcp__<server>__<tool>` (e.g., `mcp__filesystem__read_file`) |
| 175 | - | 5. **Auto-respawn**: calling a tool on a stopped server wakes it up | 175 | + | 5. **Idle timeout**: servers not used for 5 minutes are automatically stopped |
| 176 | - | 6. **Routing**: tool calls are forwarded to the appropriate MCP server | 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 | |||
| 177 | 178 | ||||
| 178 | ## Slash Commands | 179 | ## Slash Commands | ||
| 179 | 180 | ||||
| @@ -210,16 +211,17 @@ Server and tool names are sanitized (non-alphanumeric characters become `_`). | |||||
| 210 | 211 | ||||
| 211 | The bridge uses a "smart lazy" strategy: | 212 | The bridge uses a "smart lazy" strategy: | ||
| 212 | 213 | ||||
| 213 | - | 1. **On startup**: all servers spawn, tools are discovered and registered | 214 | + | 1. **On startup**: cached tool definitions are registered without blocking zot startup |
| 214 | - | 2. **During use**: servers stay running for fast tool calls | 215 | + | 2. **In the background**: servers start long enough to refresh the tool cache |
| 215 | - | 3. **After 5 min idle**: unused servers are automatically stopped (saves memory/CPU) | 216 | + | 3. **During use**: servers stay running for fast tool calls |
| 216 | - | 4. **On next tool call**: the server is respawned automatically (~1-3s delay) | 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) | |||
| 217 | 219 | ||||
| 218 | This gives you: | 220 | This gives you: | ||
| 219 | - | - ✅ All tools visible to the LLM immediately | 221 | + | - Cached tools visible to the LLM immediately |
| 220 | - | - ✅ Fast tool calls when actively working | 222 | + | - Fast tool calls when actively working |
| 221 | - | - ✅ Memory freed when not using MCP tools | 223 | + | - Memory freed when not using MCP tools |
| 222 | - | - ✅ No manual server management needed | 224 | + | - One manual `/reload-ext` only when tool definitions change |
| 223 | 225 | ||||
| 224 | ## Troubleshooting | 226 | ## Troubleshooting | ||
| 225 | 227 | ||||
M examples/extensions/mcp-bridge/main.go +48 -41
| @@ -50,50 +50,65 @@ func main() { | |||
| 50 | 50 | // Logger writes to stderr (captured by zot into ext logs) | |
| 51 | 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | |
| 52 | 52 | ||
| 53 | - | // Load config | |
| 54 | - | cwd, err := os.Getwd() | |
| 55 | - | if err != nil { | |
| 56 | - | logger.Fatalf("getwd: %v", err) | |
| 57 | - | } | |
| 58 | - | cfg, err := loadConfig(cwd) | |
| 59 | - | if err != nil { | |
| 60 | - | logger.Printf("config error: %v", err) | |
| 61 | - | e.Notify("error", "mcp: config error: "+err.Error()) | |
| 62 | - | } | |
| 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 | 63 | ||
| 64 | - | if len(cfg.MCPServers) == 0 { | |
| 65 | - | logger.Printf("no MCP servers configured") | |
| 66 | - | // Still register commands so user can check status | |
| 67 | - | registerCommands(e, nil) | |
| 68 | - | e.Run() | |
| 69 | - | return | |
| 70 | - | } | |
| 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 | + | } | |
| 71 | 69 | ||
| 72 | - | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) | |
| 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 | + | } | |
| 73 | 76 | ||
| 74 | - | // Create bridge | |
| 75 | - | b := newBridge(e, logger) | |
| 76 | - | b.loadServers(cfg) | |
| 77 | + | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) | |
| 77 | 78 | ||
| 78 | - | cachePath := toolCachePath() | |
| 79 | - | cache, err := readToolCache(cachePath) | |
| 80 | - | if err != nil { | |
| 81 | - | logger.Printf("tool cache error: %v", err) | |
| 82 | - | } | |
| 83 | - | cachedToolCount := b.registerCachedTools(cache) | |
| 84 | - | logger.Printf("registered %d cached MCP tool(s)", cachedToolCount) | |
| 79 | + | b = newBridge(e, logger) | |
| 80 | + | b.loadServers(cfg) | |
| 85 | 81 | ||
| 86 | - | // Start idle reaper | |
| 87 | - | b.startIdleReaper() | |
| 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) | |
| 88 | 89 | ||
| 89 | - | // Register slash commands | |
| 90 | - | registerCommands(e, b) | |
| 90 | + | b.startIdleReaper() | |
| 91 | + | registerCommands(e, b) | |
| 92 | + | startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount) | |
| 93 | + | }) | |
| 91 | 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) { | |
| 92 | 107 | // Refresh discovery in the background so zot startup is not blocked. Only ask | |
| 93 | 108 | // for /reload-ext if the discovered tool cache actually changed; otherwise a | |
| 94 | 109 | // reload would just repeat this cycle without adding anything. | |
| 95 | 110 | go func() { | |
| 96 | - | time.Sleep(500 * time.Millisecond) // Wait for hello handshake | |
| 111 | + | time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first. | |
| 97 | 112 | if cachedToolCount == 0 { | |
| 98 | 113 | notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.") | |
| 99 | 114 | } else { | |
| @@ -114,14 +129,6 @@ func main() { | |||
| 114 | 129 | } | |
| 115 | 130 | notifyBridgeStatus(e, b) | |
| 116 | 131 | }() | |
| 117 | - | ||
| 118 | - | // Run the extension protocol loop | |
| 119 | - | if err := e.Run(); err != nil { | |
| 120 | - | logger.Printf("fatal: %v", err) | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // Cleanup | |
| 124 | - | b.stopAll() | |
| 125 | 132 | } | |
| 126 | 133 | ||
| 127 | 134 | // registerCommands sets up the /mcp slash commands. | |
| @@ -50,50 +50,65 @@ func main() { | |||||
| 50 | // Logger writes to stderr (captured by zot into ext logs) | 50 | // Logger writes to stderr (captured by zot into ext logs) | ||
| 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | ||
| 52 | 52 | ||||
| 53 | - | // Load config | 53 | + | var b *bridge |
| 54 | - | cwd, err := os.Getwd() | 54 | + | e.OnHello(func(host ext.HostInfo) { |
| 55 | - | if err != nil { | 55 | + | cwd := host.CWD |
| 56 | - | logger.Fatalf("getwd: %v", err) | 56 | + | if cwd == "" { |
| 57 | - | } | 57 | + | var err error |
| 58 | - | cfg, err := loadConfig(cwd) | 58 | + | cwd, err = os.Getwd() |
| 59 | - | if err != nil { | 59 | + | if err != nil { |
| 60 | - | logger.Printf("config error: %v", err) | 60 | + | logger.Printf("getwd fallback: %v", err) |
| 61 | - | e.Notify("error", "mcp: config error: "+err.Error()) | 61 | + | } |
| 62 | - | } | 62 | + | } |
| 63 | 63 | ||||
| 64 | - | if len(cfg.MCPServers) == 0 { | 64 | + | cfg, err := loadConfig(cwd) |
| 65 | - | logger.Printf("no MCP servers configured") | 65 | + | if err != nil { |
| 66 | - | // Still register commands so user can check status | 66 | + | logger.Printf("config error: %v", err) |
| 67 | - | registerCommands(e, nil) | 67 | + | notifyText(e, "error", "mcp: config error: "+err.Error()) |
| 68 | - | e.Run() | 68 | + | } |
| 69 | - | return | |||
| 70 | - | } | |||
| 71 | 69 | ||||
| 72 | - | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) | 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 | + | } | |||
| 73 | 76 | ||||
| 74 | - | // Create bridge | 77 | + | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) |
| 75 | - | b := newBridge(e, logger) | |||
| 76 | - | b.loadServers(cfg) | |||
| 77 | 78 | ||||
| 78 | - | cachePath := toolCachePath() | 79 | + | b = newBridge(e, logger) |
| 79 | - | cache, err := readToolCache(cachePath) | 80 | + | b.loadServers(cfg) |
| 80 | - | if err != nil { | |||
| 81 | - | logger.Printf("tool cache error: %v", err) | |||
| 82 | - | } | |||
| 83 | - | cachedToolCount := b.registerCachedTools(cache) | |||
| 84 | - | logger.Printf("registered %d cached MCP tool(s)", cachedToolCount) | |||
| 85 | 81 | ||||
| 86 | - | // Start idle reaper | 82 | + | cachePath := toolCachePath() |
| 87 | - | b.startIdleReaper() | 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) | |||
| 88 | 89 | ||||
| 89 | - | // Register slash commands | 90 | + | b.startIdleReaper() |
| 90 | - | registerCommands(e, b) | 91 | + | registerCommands(e, b) |
| 92 | + | startBackgroundToolRefresh(e, b, logger, cachePath, cachedToolCount) | |||
| 93 | + | }) | |||
| 91 | 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) { | |||
| 92 | // Refresh discovery in the background so zot startup is not blocked. Only ask | 107 | // Refresh discovery in the background so zot startup is not blocked. Only ask | ||
| 93 | // for /reload-ext if the discovered tool cache actually changed; otherwise a | 108 | // for /reload-ext if the discovered tool cache actually changed; otherwise a | ||
| 94 | // reload would just repeat this cycle without adding anything. | 109 | // reload would just repeat this cycle without adding anything. | ||
| 95 | go func() { | 110 | go func() { | ||
| 96 | - | time.Sleep(500 * time.Millisecond) // Wait for hello handshake | 111 | + | time.Sleep(500 * time.Millisecond) // Let ready/registrations flush first. |
| 97 | if cachedToolCount == 0 { | 112 | if cachedToolCount == 0 { | ||
| 98 | notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.") | 113 | notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.") | ||
| 99 | } else { | 114 | } else { | ||
| @@ -114,14 +129,6 @@ func main() { | |||||
| 114 | } | 129 | } | ||
| 115 | notifyBridgeStatus(e, b) | 130 | notifyBridgeStatus(e, b) | ||
| 116 | }() | 131 | }() | ||
| 117 | - | ||||
| 118 | - | // Run the extension protocol loop | |||
| 119 | - | if err := e.Run(); err != nil { | |||
| 120 | - | logger.Printf("fatal: %v", err) | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // Cleanup | |||
| 124 | - | b.stopAll() | |||
| 125 | } | 132 | } | ||
| 126 | 133 | ||||
| 127 | // registerCommands sets up the /mcp slash commands. | 134 | // registerCommands sets up the /mcp slash commands. | ||
M packages/agent/ext/ext.go +53 -17
| @@ -22,6 +22,11 @@ | |||
| 22 | 22 | // Build it, drop the binary + an extension.json next to it under | |
| 23 | 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 | 30 | // The same wire format also has reference clients in TypeScript and | |
| 26 | 31 | // Python under examples/extensions/. Use whichever language fits. | |
| 27 | 32 | package ext | |
| @@ -257,6 +262,7 @@ type Extension struct { | |||
| 257 | 262 | interceptAssistant AssistantMessageHandler | |
| 258 | 263 | panelKeys map[string]func(key, text string) | |
| 259 | 264 | panelCloses map[string]func() | |
| 265 | + | onHello func(HostInfo) | |
| 260 | 266 | ||
| 261 | 267 | // Caps reported in the hello frame. | |
| 262 | 268 | caps []string | |
| @@ -309,6 +315,17 @@ func New(name, version string) *Extension { | |||
| 309 | 315 | // Returns the zero value if Run hasn't started yet. | |
| 310 | 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 | 329 | // Logf writes a line to the extension's stderr, which zot captures to | |
| 313 | 330 | // $ZOT_HOME/logs/ext-<name>.log. Use this for debug output: anything | |
| 314 | 331 | // you print to stdout would corrupt the JSON wire protocol. | |
| @@ -451,9 +468,12 @@ func (e *Extension) Notify(level, message string) { | |||
| 451 | 468 | // Run starts the protocol loop. Blocks until stdin closes (zot has | |
| 452 | 469 | // shut us down). Returns the first fatal error, or nil on clean exit. | |
| 453 | 470 | 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. | |
| 457 | 477 | if err := e.send(extproto.HelloFromExt{ | |
| 458 | 478 | Type: "hello", | |
| 459 | 479 | Name: e.name, | |
| @@ -462,6 +482,35 @@ func (e *Extension) Run() error { | |||
| 462 | 482 | }); err != nil { | |
| 463 | 483 | 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 | 514 | e.mu.Lock() | |
| 466 | 515 | descs := append([]descTuple(nil), e.descriptions...) | |
| 467 | 516 | toolDefs := append([]toolDef(nil), e.toolDefs...) | |
| @@ -508,8 +557,6 @@ func (e *Extension) Run() error { | |||
| 508 | 557 | // land in time for the typical use case. | |
| 509 | 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 | 560 | for scanner.Scan() { | |
| 514 | 561 | line := scanner.Bytes() | |
| 515 | 562 | var frame extproto.Frame | |
| @@ -519,18 +566,7 @@ func (e *Extension) Run() error { | |||
| 519 | 566 | } | |
| 520 | 567 | switch frame.Type { | |
| 521 | 568 | 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. | |
| 534 | 570 | case "command_invoked": | |
| 535 | 571 | var ci extproto.CommandInvokedFromHost | |
| 536 | 572 | if err := json.Unmarshal(line, &ci); err != nil { | |
| @@ -22,6 +22,11 @@ | |||||
| 22 | // Build it, drop the binary + an extension.json next to it under | 22 | // 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 and | 30 | // 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 ext | 32 | package ext | ||
| @@ -257,6 +262,7 @@ type Extension struct { | |||||
| 257 | interceptAssistant AssistantMessageHandler | 262 | 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 []string | 268 | 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 to | 329 | // 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: anything | 330 | // $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 has | 468 | // 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 calls | 471 | + | scanner := bufio.NewScanner(e.in) |
| 455 | - | // made before Run, and is also fine for those made after via | 472 | + | 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 err | 483 | 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.Frame | 562 | 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.HelloAckFromHost | 569 | + | // 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.CommandInvokedFromHost | 571 | 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) { | |||
| 137 | 137 | ||
| 138 | 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 | 192 | // TestOpenPanelEmitsCorrectFrame checks that e.OpenPanel sends a | |
| 141 | 193 | // well-formed open_panel frame with the correct PanelSpec fields. | |
| 142 | 194 | 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 a | 192 | // 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`: | |||
| 106 | 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` | |
| 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: | |
| 113 | 114 | ||
| 114 | 115 | ```json | |
| 115 | 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 | 123 | If you don't send `ready`, zot's idle watchdog auto-treats you as | |
| 123 | 124 | 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 | 131 | ### Runtime frames | |
| 127 | 132 | ||
| @@ -171,9 +176,10 @@ never stalls the agent. | |||
| 171 | 176 | ## Important rules | |
| 172 | 177 | ||
| 173 | 178 | - **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`). | |
| 177 | 183 | - **One JSON object per line.** No multi-line JSON. Always end | |
| 178 | 184 | every frame with `\n`. | |
| 179 | 185 | - **Flush after writing.** Most stdout writes are line-buffered when | |
| @@ -212,6 +218,15 @@ func main() { | |||
| 212 | 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 | 230 | if err := e.Run(); err != nil { | |
| 216 | 231 | e.Logf("fatal: %v", err) | |
| 217 | 232 | } | |
| @@ -220,6 +235,11 @@ func main() { | |||
| 220 | 235 | ||
| 221 | 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 | 243 | `extension.json`: | |
| 224 | 244 | ```json | |
| 225 | 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 | 264 | send({ type: "hello", name: "scratchpad", version: "1.0.0", | |
| 245 | 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 | 267 | const rl = createInterface({ input: stdin, crlfDelay: Infinity }); | |
| 253 | 268 | rl.on("line", (line) => { | |
| 254 | 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 | 278 | send({ type: "command_response", id: f.id, action: "display", | |
| 257 | 279 | display: `noted: ${f.args}` }); | |
| 258 | 280 | } else if (f.type === "tool_call" && f.name === "read_notes") { | |
| @@ -282,12 +304,14 @@ import json, sys | |||
| 282 | 304 | def emit(o): sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush() | |
| 283 | 305 | ||
| 284 | 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 | 308 | for line in sys.stdin: | |
| 289 | 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 | 315 | emit({"type": "command_response", "id": msg["id"], | |
| 292 | 316 | "action": "prompt", "prompt": "Say hi briefly."}) | |
| 293 | 317 | 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 | ```json | 115 | ```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 as | 123 | 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 when | 124 | 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 frames | 131 | ### Runtime frames | ||
| 127 | 132 | ||||
| @@ -171,9 +176,10 @@ never stalls the agent. | |||||
| 171 | ## Important rules | 176 | ## Important rules | ||
| 172 | 177 | ||||
| 173 | - **stdout is reserved for the protocol.** Anything you print to | 178 | - **stdout is reserved for the protocol.** Anything you print to | ||
| 174 | - | stdout that isn't a JSON frame breaks the wire. Use stderr for | 179 | + | stdout that isn't a JSON frame breaks the wire. The first stdout |
| 175 | - | logs / debug output (zot captures stderr to | 180 | + | 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 end | 183 | - **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 when | 185 | - **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 | ```json | 244 | ```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": | ||