Add MCP bridge extension example
- commit
- f3d4acfbe3a95e8bff4c2fbfd6d362a8c857b25b
- author
- mi-skam · · 2026-07-01 22:31 UTC
- committer
- mi-skam · · 2026-07-01 23:23 UTC
- parent
- 92241f6
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
18 files · +2659 -0
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 +286 -0
| @@ -0,0 +1,286 @@ | |||
| 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** — servers spawn on startup to discover tools, 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** — your MCP tools are ready! | |
| 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, 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 | |
| 177 | + | ||
| 178 | + | ## Slash Commands | |
| 179 | + | ||
| 180 | + | | Command | Description | | |
| 181 | + | |---|---| | |
| 182 | + | | `/mcp` | Show status of all configured servers | | |
| 183 | + | | `/mcp help` | Show available MCP commands | | |
| 184 | + | | `/mcp <name>` | Show detailed status for one server | | |
| 185 | + | | `/mcp start <name>` | Manually start a server | | |
| 186 | + | | `/mcp stop <name>` | Manually stop a server | | |
| 187 | + | | `/mcp restart` | Restart all servers | | |
| 188 | + | | `/mcp start all` | Manually start all servers | | |
| 189 | + | | `/mcp stop all` | Manually stop all servers | | |
| 190 | + | | `/mcp setup templates` | Show available setup templates | | |
| 191 | + | | `/mcp setup add <template> [--global|--project] [--name <server-name>]` | Add a server from a template | | |
| 192 | + | ||
| 193 | + | ## Tool Naming | |
| 194 | + | ||
| 195 | + | Tools are namespaced to avoid collisions with zot's built-in tools: | |
| 196 | + | ||
| 197 | + | ``` | |
| 198 | + | mcp__<server>__<tool> | |
| 199 | + | ``` | |
| 200 | + | ||
| 201 | + | Examples: | |
| 202 | + | - `mcp__filesystem__read_file` | |
| 203 | + | - `mcp__filesystem__write_file` | |
| 204 | + | - `mcp__sqlite__query` | |
| 205 | + | - `mcp__context7__resolve-library-id` | |
| 206 | + | ||
| 207 | + | Server and tool names are sanitized (non-alphanumeric characters become `_`). | |
| 208 | + | ||
| 209 | + | ## Smart Lazy Loading | |
| 210 | + | ||
| 211 | + | The bridge uses a "smart lazy" strategy: | |
| 212 | + | ||
| 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) | |
| 217 | + | ||
| 218 | + | 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 | |
| 223 | + | ||
| 224 | + | ## Troubleshooting | |
| 225 | + | ||
| 226 | + | **Check server status:** | |
| 227 | + | ``` | |
| 228 | + | /mcp | |
| 229 | + | ``` | |
| 230 | + | ||
| 231 | + | **View extension logs:** | |
| 232 | + | ```bash | |
| 233 | + | zot ext logs mcp -f | |
| 234 | + | ``` | |
| 235 | + | ||
| 236 | + | **Common issues:** | |
| 237 | + | ||
| 238 | + | - **Server fails to start**: check that `command` exists in your PATH, or use absolute path | |
| 239 | + | - **Tool not found**: run `/mcp` to see if the server started successfully | |
| 240 | + | - **Slow first call**: server is respawning after idle timeout (normal) | |
| 241 | + | ||
| 242 | + | ## Limitations | |
| 243 | + | ||
| 244 | + | - **No OAuth flow** — authentication requires manual token configuration in headers | |
| 245 | + | - **No resources/prompts** — only tools are bridged (MCP resources and prompts coming later) | |
| 246 | + | - **No automatic config hot reload** — run `/reload-ext` after setup/config changes | |
| 247 | + | ||
| 248 | + | ## Development | |
| 249 | + | ||
| 250 | + | ```bash | |
| 251 | + | # Build | |
| 252 | + | cd examples/extensions/mcp-bridge | |
| 253 | + | go build -o mcp-bridge . | |
| 254 | + | ||
| 255 | + | # Test | |
| 256 | + | go test ./... | |
| 257 | + | go vet ./... | |
| 258 | + | ||
| 259 | + | # Run without installing (for one zot session) | |
| 260 | + | zot --ext . | |
| 261 | + | ||
| 262 | + | # View logs | |
| 263 | + | zot ext logs mcp -f | |
| 264 | + | ``` | |
| 265 | + | ||
| 266 | + | ## License | |
| 267 | + | ||
| 268 | + | MIT | |
| 269 | + | ||
| 270 | + | ## Testing | |
| 271 | + | ||
| 272 | + | ```bash | |
| 273 | + | go test ./... | |
| 274 | + | go vet ./... | |
| 275 | + | go build -o /tmp/mcp-bridge . | |
| 276 | + | ``` | |
| 277 | + | ||
| 278 | + | Tested MCP servers: | |
| 279 | + | ||
| 280 | + | | Server | Transport | Result | | |
| 281 | + | |---|---|---| | |
| 282 | + | | `@modelcontextprotocol/server-filesystem` | stdio | 14 tools registered; file operations and MCP errors handled correctly | | |
| 283 | + | | grep.app `https://mcp.grep.app/` | streamable-http | `searchGitHub` registered and successfully searched public GitHub code | | |
| 284 | + | ||
| 285 | + | 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`. | |
| 286 | + | ||
| @@ -0,0 +1,286 @@ | |||||
| 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** — servers spawn on startup to discover tools, 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** — your MCP tools are ready! | |||
| 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, 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 | |||
| 177 | + | ||||
| 178 | + | ## Slash Commands | |||
| 179 | + | ||||
| 180 | + | | Command | Description | | |||
| 181 | + | |---|---| | |||
| 182 | + | | `/mcp` | Show status of all configured servers | | |||
| 183 | + | | `/mcp help` | Show available MCP commands | | |||
| 184 | + | | `/mcp <name>` | Show detailed status for one server | | |||
| 185 | + | | `/mcp start <name>` | Manually start a server | | |||
| 186 | + | | `/mcp stop <name>` | Manually stop a server | | |||
| 187 | + | | `/mcp restart` | Restart all servers | | |||
| 188 | + | | `/mcp start all` | Manually start all servers | | |||
| 189 | + | | `/mcp stop all` | Manually stop all servers | | |||
| 190 | + | | `/mcp setup templates` | Show available setup templates | | |||
| 191 | + | | `/mcp setup add <template> [--global|--project] [--name <server-name>]` | Add a server from a template | | |||
| 192 | + | ||||
| 193 | + | ## Tool Naming | |||
| 194 | + | ||||
| 195 | + | Tools are namespaced to avoid collisions with zot's built-in tools: | |||
| 196 | + | ||||
| 197 | + | ``` | |||
| 198 | + | mcp__<server>__<tool> | |||
| 199 | + | ``` | |||
| 200 | + | ||||
| 201 | + | Examples: | |||
| 202 | + | - `mcp__filesystem__read_file` | |||
| 203 | + | - `mcp__filesystem__write_file` | |||
| 204 | + | - `mcp__sqlite__query` | |||
| 205 | + | - `mcp__context7__resolve-library-id` | |||
| 206 | + | ||||
| 207 | + | Server and tool names are sanitized (non-alphanumeric characters become `_`). | |||
| 208 | + | ||||
| 209 | + | ## Smart Lazy Loading | |||
| 210 | + | ||||
| 211 | + | The bridge uses a "smart lazy" strategy: | |||
| 212 | + | ||||
| 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) | |||
| 217 | + | ||||
| 218 | + | 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 | |||
| 223 | + | ||||
| 224 | + | ## Troubleshooting | |||
| 225 | + | ||||
| 226 | + | **Check server status:** | |||
| 227 | + | ``` | |||
| 228 | + | /mcp | |||
| 229 | + | ``` | |||
| 230 | + | ||||
| 231 | + | **View extension logs:** | |||
| 232 | + | ```bash | |||
| 233 | + | zot ext logs mcp -f | |||
| 234 | + | ``` | |||
| 235 | + | ||||
| 236 | + | **Common issues:** | |||
| 237 | + | ||||
| 238 | + | - **Server fails to start**: check that `command` exists in your PATH, or use absolute path | |||
| 239 | + | - **Tool not found**: run `/mcp` to see if the server started successfully | |||
| 240 | + | - **Slow first call**: server is respawning after idle timeout (normal) | |||
| 241 | + | ||||
| 242 | + | ## Limitations | |||
| 243 | + | ||||
| 244 | + | - **No OAuth flow** — authentication requires manual token configuration in headers | |||
| 245 | + | - **No resources/prompts** — only tools are bridged (MCP resources and prompts coming later) | |||
| 246 | + | - **No automatic config hot reload** — run `/reload-ext` after setup/config changes | |||
| 247 | + | ||||
| 248 | + | ## Development | |||
| 249 | + | ||||
| 250 | + | ```bash | |||
| 251 | + | # Build | |||
| 252 | + | cd examples/extensions/mcp-bridge | |||
| 253 | + | go build -o mcp-bridge . | |||
| 254 | + | ||||
| 255 | + | # Test | |||
| 256 | + | go test ./... | |||
| 257 | + | go vet ./... | |||
| 258 | + | ||||
| 259 | + | # Run without installing (for one zot session) | |||
| 260 | + | zot --ext . | |||
| 261 | + | ||||
| 262 | + | # View logs | |||
| 263 | + | zot ext logs mcp -f | |||
| 264 | + | ``` | |||
| 265 | + | ||||
| 266 | + | ## License | |||
| 267 | + | ||||
| 268 | + | MIT | |||
| 269 | + | ||||
| 270 | + | ## Testing | |||
| 271 | + | ||||
| 272 | + | ```bash | |||
| 273 | + | go test ./... | |||
| 274 | + | go vet ./... | |||
| 275 | + | go build -o /tmp/mcp-bridge . | |||
| 276 | + | ``` | |||
| 277 | + | ||||
| 278 | + | Tested MCP servers: | |||
| 279 | + | ||||
| 280 | + | | Server | Transport | Result | | |||
| 281 | + | |---|---|---| | |||
| 282 | + | | `@modelcontextprotocol/server-filesystem` | stdio | 14 tools registered; file operations and MCP errors handled correctly | | |||
| 283 | + | | grep.app `https://mcp.grep.app/` | streamable-http | `searchGitHub` registered and successfully searched public GitHub code | | |||
| 284 | + | ||||
| 285 | + | 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`. | |||
| 286 | + | ||||
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 generated file: collapsed
A examples/extensions/mcp-bridge/main.go +340 -0
| @@ -0,0 +1,340 @@ | |||
| 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 | + | // 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 | + | } | |
| 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 | + | } | |
| 71 | + | ||
| 72 | + | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) | |
| 73 | + | ||
| 74 | + | // Create bridge | |
| 75 | + | b := newBridge(e, logger) | |
| 76 | + | b.loadServers(cfg) | |
| 77 | + | ||
| 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) | |
| 85 | + | ||
| 86 | + | // Start idle reaper | |
| 87 | + | b.startIdleReaper() | |
| 88 | + | ||
| 89 | + | // Register slash commands | |
| 90 | + | registerCommands(e, b) | |
| 91 | + | ||
| 92 | + | // 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 | |
| 94 | + | // reload would just repeat this cycle without adding anything. | |
| 95 | + | go func() { | |
| 96 | + | time.Sleep(500 * time.Millisecond) // Wait for hello handshake | |
| 97 | + | if cachedToolCount == 0 { | |
| 98 | + | notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.") | |
| 99 | + | } else { | |
| 100 | + | notifyText(e, "success", fmt.Sprintf("MCP loaded %d cached tool(s). Refreshing cache in background.", cachedToolCount)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |
| 104 | + | defer cancel() | |
| 105 | + | changed, err := b.refreshToolCache(ctx, cachePath) | |
| 106 | + | if err != nil { | |
| 107 | + | logger.Printf("tool cache refresh error: %v", err) | |
| 108 | + | notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error()) | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | if changed { | |
| 112 | + | notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.") | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | notifyBridgeStatus(e, b) | |
| 116 | + | }() | |
| 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 | + | } | |
| 126 | + | ||
| 127 | + | // registerCommands sets up the /mcp slash commands. | |
| 128 | + | func registerCommands(e *ext.Extension, b *bridge) { | |
| 129 | + | e.Command("mcp", "show MCP server status or manage servers", func(args string) ext.Response { | |
| 130 | + | args = strings.TrimSpace(args) | |
| 131 | + | ||
| 132 | + | // Parse subcommand | |
| 133 | + | parts := strings.Fields(args) | |
| 134 | + | if len(parts) == 0 { | |
| 135 | + | // /mcp — show a compact dashboard: status plus discoverable actions. | |
| 136 | + | notifyText(e, "info", mcpOverview(b)) | |
| 137 | + | return ext.Noop() | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | switch parts[0] { | |
| 141 | + | case "help", "--help", "-h": | |
| 142 | + | notifyText(e, "info", mcpHelp(b)) | |
| 143 | + | return ext.Noop() | |
| 144 | + | ||
| 145 | + | case "setup": | |
| 146 | + | out, err := handleSetup(parts[1:], e.Host().CWD) | |
| 147 | + | if err != nil { | |
| 148 | + | return ext.Errorf("%v", err) | |
| 149 | + | } | |
| 150 | + | notifyText(e, "info", out) | |
| 151 | + | return ext.Noop() | |
| 152 | + | ||
| 153 | + | case "start": | |
| 154 | + | return handleStartCommand(e, b, parts[1:]) | |
| 155 | + | ||
| 156 | + | case "stop": | |
| 157 | + | return handleStopCommand(e, b, parts[1:]) | |
| 158 | + | ||
| 159 | + | case "restart": | |
| 160 | + | return handleRestartCommand(e, b) | |
| 161 | + | ||
| 162 | + | case "refresh", "discover": | |
| 163 | + | return handleRefreshCommand(e, b, toolCachePath()) | |
| 164 | + | ||
| 165 | + | default: | |
| 166 | + | // /mcp <name> — show detailed status for one server | |
| 167 | + | if b == nil { | |
| 168 | + | return ext.Errorf("no servers configured") | |
| 169 | + | } | |
| 170 | + | name := parts[0] | |
| 171 | + | srv, ok := b.servers[name] | |
| 172 | + | if !ok { | |
| 173 | + | return ext.Errorf("unknown server: %s", name) | |
| 174 | + | } | |
| 175 | + | notifyText(e, serverNotifyLevel(srv), srv.detailStatus()) | |
| 176 | + | return ext.Noop() | |
| 177 | + | } | |
| 178 | + | }) | |
| 179 | + | ||
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func mcpOverview(b *bridge) string { | |
| 183 | + | var sb strings.Builder | |
| 184 | + | sb.WriteString("MCP status\n") | |
| 185 | + | if b == nil || len(b.servers) == 0 { | |
| 186 | + | sb.WriteString(" no servers configured\n") | |
| 187 | + | } else { | |
| 188 | + | for _, line := range b.serverStatus() { | |
| 189 | + | sb.WriteString(" ") | |
| 190 | + | sb.WriteString(line) | |
| 191 | + | sb.WriteByte('\n') | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | sb.WriteString("\nCommon commands\n") | |
| 195 | + | sb.WriteString(" /mcp <server> Show detailed status for one server\n") | |
| 196 | + | sb.WriteString(" /mcp start <server|all> Start one server, or all servers\n") | |
| 197 | + | sb.WriteString(" /mcp stop <server|all> Stop one server, or all servers\n") | |
| 198 | + | sb.WriteString(" /mcp restart Restart all servers\n") | |
| 199 | + | sb.WriteString(" /mcp refresh Refresh cached tool definitions\n") | |
| 200 | + | sb.WriteString(" /mcp help Show all MCP commands\n") | |
| 201 | + | return strings.TrimRight(sb.String(), "\n") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func mcpHelp(b *bridge) string { | |
| 205 | + | var sb strings.Builder | |
| 206 | + | sb.WriteString(mcpOverview(b)) | |
| 207 | + | sb.WriteString("\n\nSetup commands\n") | |
| 208 | + | sb.WriteString(" /mcp setup templates List setup templates\n") | |
| 209 | + | sb.WriteString(" /mcp setup add <template> [options] Add a server template\n") | |
| 210 | + | sb.WriteString("\nSetup options\n") | |
| 211 | + | sb.WriteString(" --global Write to $ZOT_HOME/mcp.json (default)\n") | |
| 212 | + | sb.WriteString(" --project Write to .zot/mcp.json\n") | |
| 213 | + | sb.WriteString(" --name <server-name> Use a custom configured server name\n") | |
| 214 | + | return strings.TrimRight(sb.String(), "\n") | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func handleStartCommand(e *ext.Extension, b *bridge, args []string) ext.Response { | |
| 218 | + | if len(args) != 1 { | |
| 219 | + | return ext.Errorf("usage: /mcp start <server-name|all>") | |
| 220 | + | } | |
| 221 | + | if b == nil { | |
| 222 | + | return ext.Errorf("no servers configured") | |
| 223 | + | } | |
| 224 | + | name := args[0] | |
| 225 | + | if name == "all" { | |
| 226 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |
| 227 | + | defer cancel() | |
| 228 | + | if err := b.startAll(ctx); err != nil { | |
| 229 | + | return ext.Errorf("start all failed: %v", err) | |
| 230 | + | } | |
| 231 | + | notifyBridgeStatus(e, b) | |
| 232 | + | return ext.Noop() | |
| 233 | + | } | |
| 234 | + | if err := b.startServer(name); err != nil { | |
| 235 | + | return ext.Errorf("start %s: %v", name, err) | |
| 236 | + | } | |
| 237 | + | notifyBridgeStatus(e, b) | |
| 238 | + | return ext.Noop() | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func handleStopCommand(e *ext.Extension, b *bridge, args []string) ext.Response { | |
| 242 | + | if len(args) != 1 { | |
| 243 | + | return ext.Errorf("usage: /mcp stop <server-name|all>") | |
| 244 | + | } | |
| 245 | + | if b == nil { | |
| 246 | + | return ext.Errorf("no servers configured") | |
| 247 | + | } | |
| 248 | + | name := args[0] | |
| 249 | + | if name == "all" { | |
| 250 | + | b.stopAll() | |
| 251 | + | notifyBridgeStatus(e, b) | |
| 252 | + | return ext.Noop() | |
| 253 | + | } | |
| 254 | + | if err := b.stopServer(name); err != nil { | |
| 255 | + | return ext.Errorf("stop %s: %v", name, err) | |
| 256 | + | } | |
| 257 | + | notifyBridgeStatus(e, b) | |
| 258 | + | return ext.Noop() | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func handleRestartCommand(e *ext.Extension, b *bridge) ext.Response { | |
| 262 | + | if b == nil { | |
| 263 | + | return ext.Errorf("no servers configured") | |
| 264 | + | } | |
| 265 | + | b.stopAll() | |
| 266 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |
| 267 | + | defer cancel() | |
| 268 | + | if err := b.startAll(ctx); err != nil { | |
| 269 | + | return ext.Errorf("restart failed: %v", err) | |
| 270 | + | } | |
| 271 | + | notifyBridgeStatus(e, b) | |
| 272 | + | return ext.Noop() | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func handleRefreshCommand(e *ext.Extension, b *bridge, cachePath string) ext.Response { | |
| 276 | + | if b == nil { | |
| 277 | + | return ext.Errorf("no servers configured") | |
| 278 | + | } | |
| 279 | + | notifyText(e, "info", "Refreshing MCP tool cache…") | |
| 280 | + | go func() { | |
| 281 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |
| 282 | + | defer cancel() | |
| 283 | + | changed, err := b.refreshToolCache(ctx, cachePath) | |
| 284 | + | if err != nil { | |
| 285 | + | notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error()) | |
| 286 | + | return | |
| 287 | + | } | |
| 288 | + | if changed { | |
| 289 | + | notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.") | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | notifyBridgeStatus(e, b) | |
| 293 | + | }() | |
| 294 | + | return ext.Noop() | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func notifyBridgeStatus(e *ext.Extension, b *bridge) { | |
| 298 | + | if e == nil || b == nil { | |
| 299 | + | return | |
| 300 | + | } | |
| 301 | + | notifyText(e, b.notifyLevel(), formatStatusSummary(b)) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func serverNotifyLevel(srv *managedServer) string { | |
| 305 | + | if srv == nil { | |
| 306 | + | return "info" | |
| 307 | + | } | |
| 308 | + | srv.mu.Lock() | |
| 309 | + | state := srv.state | |
| 310 | + | srv.mu.Unlock() | |
| 311 | + | switch state { | |
| 312 | + | case stateReady: | |
| 313 | + | return "success" | |
| 314 | + | case stateError: | |
| 315 | + | return "error" | |
| 316 | + | default: | |
| 317 | + | return "info" | |
| 318 | + | } | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func notifyText(e *ext.Extension, level, text string) { | |
| 322 | + | if e == nil { | |
| 323 | + | return | |
| 324 | + | } | |
| 325 | + | clearExtensionNotes() | |
| 326 | + | e.Notify(level, text) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func clearExtensionNotes() { | |
| 330 | + | _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n") | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | // formatStatusSummary builds a human-readable status line. | |
| 334 | + | func formatStatusSummary(b *bridge) string { | |
| 335 | + | lines := b.serverStatus() | |
| 336 | + | if len(lines) == 0 { | |
| 337 | + | return "no MCP servers" | |
| 338 | + | } | |
| 339 | + | return strings.Join(lines, " | ") | |
| 340 | + | } | |
| @@ -0,0 +1,340 @@ | |||||
| 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 | + | // 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 | + | } | |||
| 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 | + | } | |||
| 71 | + | ||||
| 72 | + | logger.Printf("found %d MCP server(s)", len(cfg.MCPServers)) | |||
| 73 | + | ||||
| 74 | + | // Create bridge | |||
| 75 | + | b := newBridge(e, logger) | |||
| 76 | + | b.loadServers(cfg) | |||
| 77 | + | ||||
| 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) | |||
| 85 | + | ||||
| 86 | + | // Start idle reaper | |||
| 87 | + | b.startIdleReaper() | |||
| 88 | + | ||||
| 89 | + | // Register slash commands | |||
| 90 | + | registerCommands(e, b) | |||
| 91 | + | ||||
| 92 | + | // 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 | |||
| 94 | + | // reload would just repeat this cycle without adding anything. | |||
| 95 | + | go func() { | |||
| 96 | + | time.Sleep(500 * time.Millisecond) // Wait for hello handshake | |||
| 97 | + | if cachedToolCount == 0 { | |||
| 98 | + | notifyText(e, "warn", "MCP loaded without cached tools. Refreshing tool cache in background.") | |||
| 99 | + | } else { | |||
| 100 | + | notifyText(e, "success", fmt.Sprintf("MCP loaded %d cached tool(s). Refreshing cache in background.", cachedToolCount)) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |||
| 104 | + | defer cancel() | |||
| 105 | + | changed, err := b.refreshToolCache(ctx, cachePath) | |||
| 106 | + | if err != nil { | |||
| 107 | + | logger.Printf("tool cache refresh error: %v", err) | |||
| 108 | + | notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error()) | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | if changed { | |||
| 112 | + | notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.") | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | notifyBridgeStatus(e, b) | |||
| 116 | + | }() | |||
| 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 | + | } | |||
| 126 | + | ||||
| 127 | + | // registerCommands sets up the /mcp slash commands. | |||
| 128 | + | func registerCommands(e *ext.Extension, b *bridge) { | |||
| 129 | + | e.Command("mcp", "show MCP server status or manage servers", func(args string) ext.Response { | |||
| 130 | + | args = strings.TrimSpace(args) | |||
| 131 | + | ||||
| 132 | + | // Parse subcommand | |||
| 133 | + | parts := strings.Fields(args) | |||
| 134 | + | if len(parts) == 0 { | |||
| 135 | + | // /mcp — show a compact dashboard: status plus discoverable actions. | |||
| 136 | + | notifyText(e, "info", mcpOverview(b)) | |||
| 137 | + | return ext.Noop() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | switch parts[0] { | |||
| 141 | + | case "help", "--help", "-h": | |||
| 142 | + | notifyText(e, "info", mcpHelp(b)) | |||
| 143 | + | return ext.Noop() | |||
| 144 | + | ||||
| 145 | + | case "setup": | |||
| 146 | + | out, err := handleSetup(parts[1:], e.Host().CWD) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return ext.Errorf("%v", err) | |||
| 149 | + | } | |||
| 150 | + | notifyText(e, "info", out) | |||
| 151 | + | return ext.Noop() | |||
| 152 | + | ||||
| 153 | + | case "start": | |||
| 154 | + | return handleStartCommand(e, b, parts[1:]) | |||
| 155 | + | ||||
| 156 | + | case "stop": | |||
| 157 | + | return handleStopCommand(e, b, parts[1:]) | |||
| 158 | + | ||||
| 159 | + | case "restart": | |||
| 160 | + | return handleRestartCommand(e, b) | |||
| 161 | + | ||||
| 162 | + | case "refresh", "discover": | |||
| 163 | + | return handleRefreshCommand(e, b, toolCachePath()) | |||
| 164 | + | ||||
| 165 | + | default: | |||
| 166 | + | // /mcp <name> — show detailed status for one server | |||
| 167 | + | if b == nil { | |||
| 168 | + | return ext.Errorf("no servers configured") | |||
| 169 | + | } | |||
| 170 | + | name := parts[0] | |||
| 171 | + | srv, ok := b.servers[name] | |||
| 172 | + | if !ok { | |||
| 173 | + | return ext.Errorf("unknown server: %s", name) | |||
| 174 | + | } | |||
| 175 | + | notifyText(e, serverNotifyLevel(srv), srv.detailStatus()) | |||
| 176 | + | return ext.Noop() | |||
| 177 | + | } | |||
| 178 | + | }) | |||
| 179 | + | ||||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func mcpOverview(b *bridge) string { | |||
| 183 | + | var sb strings.Builder | |||
| 184 | + | sb.WriteString("MCP status\n") | |||
| 185 | + | if b == nil || len(b.servers) == 0 { | |||
| 186 | + | sb.WriteString(" no servers configured\n") | |||
| 187 | + | } else { | |||
| 188 | + | for _, line := range b.serverStatus() { | |||
| 189 | + | sb.WriteString(" ") | |||
| 190 | + | sb.WriteString(line) | |||
| 191 | + | sb.WriteByte('\n') | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | sb.WriteString("\nCommon commands\n") | |||
| 195 | + | sb.WriteString(" /mcp <server> Show detailed status for one server\n") | |||
| 196 | + | sb.WriteString(" /mcp start <server|all> Start one server, or all servers\n") | |||
| 197 | + | sb.WriteString(" /mcp stop <server|all> Stop one server, or all servers\n") | |||
| 198 | + | sb.WriteString(" /mcp restart Restart all servers\n") | |||
| 199 | + | sb.WriteString(" /mcp refresh Refresh cached tool definitions\n") | |||
| 200 | + | sb.WriteString(" /mcp help Show all MCP commands\n") | |||
| 201 | + | return strings.TrimRight(sb.String(), "\n") | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func mcpHelp(b *bridge) string { | |||
| 205 | + | var sb strings.Builder | |||
| 206 | + | sb.WriteString(mcpOverview(b)) | |||
| 207 | + | sb.WriteString("\n\nSetup commands\n") | |||
| 208 | + | sb.WriteString(" /mcp setup templates List setup templates\n") | |||
| 209 | + | sb.WriteString(" /mcp setup add <template> [options] Add a server template\n") | |||
| 210 | + | sb.WriteString("\nSetup options\n") | |||
| 211 | + | sb.WriteString(" --global Write to $ZOT_HOME/mcp.json (default)\n") | |||
| 212 | + | sb.WriteString(" --project Write to .zot/mcp.json\n") | |||
| 213 | + | sb.WriteString(" --name <server-name> Use a custom configured server name\n") | |||
| 214 | + | return strings.TrimRight(sb.String(), "\n") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func handleStartCommand(e *ext.Extension, b *bridge, args []string) ext.Response { | |||
| 218 | + | if len(args) != 1 { | |||
| 219 | + | return ext.Errorf("usage: /mcp start <server-name|all>") | |||
| 220 | + | } | |||
| 221 | + | if b == nil { | |||
| 222 | + | return ext.Errorf("no servers configured") | |||
| 223 | + | } | |||
| 224 | + | name := args[0] | |||
| 225 | + | if name == "all" { | |||
| 226 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |||
| 227 | + | defer cancel() | |||
| 228 | + | if err := b.startAll(ctx); err != nil { | |||
| 229 | + | return ext.Errorf("start all failed: %v", err) | |||
| 230 | + | } | |||
| 231 | + | notifyBridgeStatus(e, b) | |||
| 232 | + | return ext.Noop() | |||
| 233 | + | } | |||
| 234 | + | if err := b.startServer(name); err != nil { | |||
| 235 | + | return ext.Errorf("start %s: %v", name, err) | |||
| 236 | + | } | |||
| 237 | + | notifyBridgeStatus(e, b) | |||
| 238 | + | return ext.Noop() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func handleStopCommand(e *ext.Extension, b *bridge, args []string) ext.Response { | |||
| 242 | + | if len(args) != 1 { | |||
| 243 | + | return ext.Errorf("usage: /mcp stop <server-name|all>") | |||
| 244 | + | } | |||
| 245 | + | if b == nil { | |||
| 246 | + | return ext.Errorf("no servers configured") | |||
| 247 | + | } | |||
| 248 | + | name := args[0] | |||
| 249 | + | if name == "all" { | |||
| 250 | + | b.stopAll() | |||
| 251 | + | notifyBridgeStatus(e, b) | |||
| 252 | + | return ext.Noop() | |||
| 253 | + | } | |||
| 254 | + | if err := b.stopServer(name); err != nil { | |||
| 255 | + | return ext.Errorf("stop %s: %v", name, err) | |||
| 256 | + | } | |||
| 257 | + | notifyBridgeStatus(e, b) | |||
| 258 | + | return ext.Noop() | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func handleRestartCommand(e *ext.Extension, b *bridge) ext.Response { | |||
| 262 | + | if b == nil { | |||
| 263 | + | return ext.Errorf("no servers configured") | |||
| 264 | + | } | |||
| 265 | + | b.stopAll() | |||
| 266 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |||
| 267 | + | defer cancel() | |||
| 268 | + | if err := b.startAll(ctx); err != nil { | |||
| 269 | + | return ext.Errorf("restart failed: %v", err) | |||
| 270 | + | } | |||
| 271 | + | notifyBridgeStatus(e, b) | |||
| 272 | + | return ext.Noop() | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func handleRefreshCommand(e *ext.Extension, b *bridge, cachePath string) ext.Response { | |||
| 276 | + | if b == nil { | |||
| 277 | + | return ext.Errorf("no servers configured") | |||
| 278 | + | } | |||
| 279 | + | notifyText(e, "info", "Refreshing MCP tool cache…") | |||
| 280 | + | go func() { | |||
| 281 | + | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | |||
| 282 | + | defer cancel() | |||
| 283 | + | changed, err := b.refreshToolCache(ctx, cachePath) | |||
| 284 | + | if err != nil { | |||
| 285 | + | notifyText(e, "warn", "MCP tool cache refresh partially failed: "+err.Error()) | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | if changed { | |||
| 289 | + | notifyText(e, "success", "MCP tool cache changed. Run /reload-ext once to load the updated tools.") | |||
| 290 | + | return | |||
| 291 | + | } | |||
| 292 | + | notifyBridgeStatus(e, b) | |||
| 293 | + | }() | |||
| 294 | + | return ext.Noop() | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func notifyBridgeStatus(e *ext.Extension, b *bridge) { | |||
| 298 | + | if e == nil || b == nil { | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | notifyText(e, b.notifyLevel(), formatStatusSummary(b)) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func serverNotifyLevel(srv *managedServer) string { | |||
| 305 | + | if srv == nil { | |||
| 306 | + | return "info" | |||
| 307 | + | } | |||
| 308 | + | srv.mu.Lock() | |||
| 309 | + | state := srv.state | |||
| 310 | + | srv.mu.Unlock() | |||
| 311 | + | switch state { | |||
| 312 | + | case stateReady: | |||
| 313 | + | return "success" | |||
| 314 | + | case stateError: | |||
| 315 | + | return "error" | |||
| 316 | + | default: | |||
| 317 | + | return "info" | |||
| 318 | + | } | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func notifyText(e *ext.Extension, level, text string) { | |||
| 322 | + | if e == nil { | |||
| 323 | + | return | |||
| 324 | + | } | |||
| 325 | + | clearExtensionNotes() | |||
| 326 | + | e.Notify(level, text) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func clearExtensionNotes() { | |||
| 330 | + | _, _ = os.Stdout.WriteString("{\"type\":\"clear_notes\"}\n") | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // formatStatusSummary builds a human-readable status line. | |||
| 334 | + | func formatStatusSummary(b *bridge) string { | |||
| 335 | + | lines := b.serverStatus() | |||
| 336 | + | if len(lines) == 0 { | |||
| 337 | + | return "no MCP servers" | |||
| 338 | + | } | |||
| 339 | + | return strings.Join(lines, " | ") | |||
| 340 | + | } | |||
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 | + | } | |||