Load MCP tool schemas on demand
Replace eager registration of every cached MCP schema with one local mcp__search_tools loader and deferred tool definitions. Rank cached names and descriptions deterministically, activate a bounded result set, and document the new flow. This keeps large MCP installations below provider request-size limits while preserving lazy server startup and normal tool execution.
- commit
- b777b5aa0cf74c5d7d1f6519c4ea5b23c8eb6c87
- author
- mi-skam · · 2026-07-21 15:55 UTC
- parent
- 9b5576b
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
5 files · +447 -12
M examples/extensions/mcp-bridge/README.md +6 -3
| @@ -7,8 +7,9 @@ This extension reads MCP server configurations from standard locations (same for | |||
| 7 | 7 | ## Features | |
| 8 | 8 | ||
| 9 | 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | |
| 10 | - | - **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time | |
| 11 | - | - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically | |
| 10 | + | - **On-demand tool discovery** — only `mcp__search_tools` is advertised initially; matching MCP schemas load when needed instead of bloating every model request | |
| 11 | + | - **Smart lazy loading** — cached definitions register as deferred tools at startup, servers wake for refresh or tool calls, then auto-sleep after idle time | |
| 12 | + | - **Auto-respawn** — calling a loaded tool on a sleeping server wakes it up automatically | |
| 12 | 13 | - **Multi-transport** — stdio, streamable-http, and SSE transports | |
| 13 | 14 | - **Multi-server** — connect to any number of MCP servers simultaneously | |
| 14 | 15 | - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions | |
| @@ -53,7 +54,9 @@ This extension reads MCP server configurations from standard locations (same for | |||
| 53 | 54 | zot ext install . | |
| 54 | 55 | ``` | |
| 55 | 56 | ||
| 56 | - | 4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately. | |
| 57 | + | 4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately as deferred definitions. | |
| 58 | + | ||
| 59 | + | The model initially sees one small loader tool, `mcp__search_tools`. It searches cached MCP tool names and descriptions locally, activates up to eight relevant definitions by default, and then calls the selected MCP tool normally. This keeps large MCP installations compatible with providers that limit request or tool-schema size. | |
| 57 | 60 | ||
| 58 | 61 | ## Configuration | |
| 59 | 62 | ||
| @@ -7,8 +7,9 @@ This extension reads MCP server configurations from standard locations (same for | |||||
| 7 | ## Features | 7 | ## Features | ||
| 8 | 8 | ||||
| 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | 9 | - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline | ||
| 10 | - | - **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time | 10 | + | - **On-demand tool discovery** — only `mcp__search_tools` is advertised initially; matching MCP schemas load when needed instead of bloating every model request |
| 11 | - | - **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically | 11 | + | - **Smart lazy loading** — cached definitions register as deferred tools at startup, servers wake for refresh or tool calls, then auto-sleep after idle time |
| 12 | + | - **Auto-respawn** — calling a loaded tool on a sleeping server wakes it up automatically | |||
| 12 | - **Multi-transport** — stdio, streamable-http, and SSE transports | 13 | - **Multi-transport** — stdio, streamable-http, and SSE transports | ||
| 13 | - **Multi-server** — connect to any number of MCP servers simultaneously | 14 | - **Multi-server** — connect to any number of MCP servers simultaneously | ||
| 14 | - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions | 15 | - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions | ||
| @@ -53,7 +54,9 @@ This extension reads MCP server configurations from standard locations (same for | |||||
| 53 | zot ext install . | 54 | zot ext install . | ||
| 54 | ``` | 55 | ``` | ||
| 55 | 56 | ||||
| 56 | - | 4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately. | 57 | + | 4. **Restart zot.** On first run the extension refreshes its tool cache in the background. When zot shows `MCP tool cache changed`, run `/reload-ext` once. Future launches register the cached MCP tools immediately as deferred definitions. |
| 58 | + | ||||
| 59 | + | The model initially sees one small loader tool, `mcp__search_tools`. It searches cached MCP tool names and descriptions locally, activates up to eight relevant definitions by default, and then calls the selected MCP tool normally. This keeps large MCP installations compatible with providers that limit request or tool-schema size. | |||
| 57 | 60 | ||||
| 58 | ## Configuration | 61 | ## Configuration | ||
| 59 | 62 | ||||
M examples/extensions/mcp-bridge/bridge.go +168 -6
| @@ -24,10 +24,35 @@ import ( | |||
| 24 | 24 | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | + | const ( | |
| 28 | + | mcpSearchToolName = "mcp__search_tools" | |
| 29 | + | defaultToolSearchLimit = 8 | |
| 30 | + | maxToolSearchLimit = 20 | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | var mcpSearchToolSchema = json.RawMessage(`{ | |
| 34 | + | "type": "object", | |
| 35 | + | "properties": { | |
| 36 | + | "query": { | |
| 37 | + | "type": "string", | |
| 38 | + | "description": "Words describing the MCP capability needed, such as 'search GitHub code' or 'list n8n workflows'." | |
| 39 | + | }, | |
| 40 | + | "limit": { | |
| 41 | + | "type": "integer", | |
| 42 | + | "minimum": 1, | |
| 43 | + | "maximum": 20, | |
| 44 | + | "description": "Maximum number of matching tools to load. Defaults to 8." | |
| 45 | + | } | |
| 46 | + | }, | |
| 47 | + | "required": ["query"], | |
| 48 | + | "additionalProperties": false | |
| 49 | + | }`) | |
| 50 | + | ||
| 27 | 51 | // toolMapping tracks which zot tool name maps to which MCP server + tool. | |
| 28 | 52 | type toolMapping struct { | |
| 29 | - | serverName string // e.g. "filesystem" | |
| 30 | - | mcpTool string // e.g. "read_file" | |
| 53 | + | serverName string // e.g. "filesystem" | |
| 54 | + | mcpTool string // e.g. "read_file" | |
| 55 | + | description string | |
| 31 | 56 | } | |
| 32 | 57 | ||
| 33 | 58 | // bridge connects MCP servers to zot's extension protocol. | |
| @@ -42,7 +67,8 @@ type bridge struct { | |||
| 42 | 67 | mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu | |
| 43 | 68 | logger *log.Logger | |
| 44 | 69 | ||
| 45 | - | mu sync.Mutex // guards mapping | |
| 70 | + | mu sync.Mutex // guards mapping and searchRegistered | |
| 71 | + | searchRegistered bool | |
| 46 | 72 | } | |
| 47 | 73 | ||
| 48 | 74 | // newBridge creates a new MCP→zot bridge. | |
| @@ -85,9 +111,30 @@ func (b *bridge) loadServers(cfg Config) { | |||
| 85 | 111 | } | |
| 86 | 112 | } | |
| 87 | 113 | ||
| 114 | + | // registerToolSearch exposes the only eager MCP tool definition. The actual | |
| 115 | + | // MCP tools stay deferred until this local catalog search activates a small, | |
| 116 | + | // relevant subset. This keeps large MCP installations from bloating every LLM | |
| 117 | + | // request and is required by providers with tight tool-schema size limits. | |
| 118 | + | func (b *bridge) registerToolSearch() { | |
| 119 | + | b.mu.Lock() | |
| 120 | + | if b.searchRegistered { | |
| 121 | + | b.mu.Unlock() | |
| 122 | + | return | |
| 123 | + | } | |
| 124 | + | b.searchRegistered = true | |
| 125 | + | b.mu.Unlock() | |
| 126 | + | ||
| 127 | + | b.e.Tool(mcpSearchToolName, | |
| 128 | + | "Search configured MCP tools by capability and load the matching tool definitions. Use this before calling an MCP tool that is not already available.", | |
| 129 | + | mcpSearchToolSchema, | |
| 130 | + | b.searchTools, | |
| 131 | + | ) | |
| 132 | + | } | |
| 133 | + | ||
| 88 | 134 | // registerCachedTools registers previously discovered tool definitions without | |
| 89 | 135 | // starting MCP servers. Tool calls still lazy-start the owning server on demand. | |
| 90 | 136 | func (b *bridge) registerCachedTools(cache toolCache) int { | |
| 137 | + | b.registerToolSearch() | |
| 91 | 138 | count := 0 | |
| 92 | 139 | for serverName, srv := range b.servers { | |
| 93 | 140 | cached, ok := cache.Servers[serverName] | |
| @@ -186,14 +233,129 @@ func (b *bridge) registerCachedTool(serverName string, tool cachedTool) { | |||
| 186 | 233 | } | |
| 187 | 234 | return | |
| 188 | 235 | } | |
| 189 | - | b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name} | |
| 236 | + | b.mapping[zotName] = toolMapping{ | |
| 237 | + | serverName: serverName, | |
| 238 | + | mcpTool: tool.Name, | |
| 239 | + | description: tool.Description, | |
| 240 | + | } | |
| 190 | 241 | b.mu.Unlock() | |
| 191 | 242 | ||
| 192 | - | b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult { | |
| 243 | + | b.e.DeferredTool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult { | |
| 193 | 244 | return b.handleToolCall(zotName, args) | |
| 194 | 245 | }) | |
| 195 | 246 | ||
| 196 | - | b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name) | |
| 247 | + | b.logger.Printf("registered deferred cached tool: %s → %s/%s", zotName, serverName, tool.Name) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | type toolSearchArgs struct { | |
| 251 | + | Query string `json:"query"` | |
| 252 | + | Limit int `json:"limit,omitempty"` | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | type toolSearchMatch struct { | |
| 256 | + | name string | |
| 257 | + | description string | |
| 258 | + | score int | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (b *bridge) searchTools(raw json.RawMessage) ext.ToolResult { | |
| 262 | + | var args toolSearchArgs | |
| 263 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 264 | + | return ext.TextErrorResult(mcpSearchToolName + ": invalid arguments: " + err.Error()) | |
| 265 | + | } | |
| 266 | + | query := strings.TrimSpace(args.Query) | |
| 267 | + | if query == "" { | |
| 268 | + | return ext.TextResult("No MCP tools loaded: provide a non-empty capability query.") | |
| 269 | + | } | |
| 270 | + | limit := args.Limit | |
| 271 | + | if limit == 0 { | |
| 272 | + | limit = defaultToolSearchLimit | |
| 273 | + | } | |
| 274 | + | if limit < 1 || limit > maxToolSearchLimit { | |
| 275 | + | return ext.TextErrorResult(fmt.Sprintf("%s: limit must be between 1 and %d", mcpSearchToolName, maxToolSearchLimit)) | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | matches := b.findTools(query, limit) | |
| 279 | + | if len(matches) == 0 { | |
| 280 | + | return ext.TextResult(fmt.Sprintf("No MCP tools matched %q. Try broader capability words or an MCP server name.", query)) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | var text strings.Builder | |
| 284 | + | fmt.Fprintf(&text, "Loaded %d MCP tool(s) matching %q:\n", len(matches), query) | |
| 285 | + | activated := make([]string, 0, len(matches)) | |
| 286 | + | for _, match := range matches { | |
| 287 | + | activated = append(activated, match.name) | |
| 288 | + | fmt.Fprintf(&text, "- %s", match.name) | |
| 289 | + | if match.description != "" { | |
| 290 | + | fmt.Fprintf(&text, ": %s", match.description) | |
| 291 | + | } | |
| 292 | + | text.WriteByte('\n') | |
| 293 | + | } | |
| 294 | + | text.WriteString("Call the appropriate loaded tool now. Search again if none fits the task.") | |
| 295 | + | return ext.ToolResult{ | |
| 296 | + | Content: []ext.ToolContent{ext.Text(text.String())}, | |
| 297 | + | ActivateTools: activated, | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func (b *bridge) findTools(query string, limit int) []toolSearchMatch { | |
| 302 | + | terms := strings.Fields(strings.ToLower(query)) | |
| 303 | + | if len(terms) == 0 || limit <= 0 { | |
| 304 | + | return nil | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | b.mu.Lock() | |
| 308 | + | matches := make([]toolSearchMatch, 0, len(b.mapping)) | |
| 309 | + | for name, mapping := range b.mapping { | |
| 310 | + | score := scoreToolMatch(terms, name, mapping) | |
| 311 | + | if score > 0 { | |
| 312 | + | matches = append(matches, toolSearchMatch{name: name, description: mapping.description, score: score}) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | b.mu.Unlock() | |
| 316 | + | ||
| 317 | + | sort.Slice(matches, func(i, j int) bool { | |
| 318 | + | if matches[i].score != matches[j].score { | |
| 319 | + | return matches[i].score > matches[j].score | |
| 320 | + | } | |
| 321 | + | return matches[i].name < matches[j].name | |
| 322 | + | }) | |
| 323 | + | if len(matches) > limit { | |
| 324 | + | matches = matches[:limit] | |
| 325 | + | } | |
| 326 | + | return matches | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func scoreToolMatch(terms []string, zotName string, mapping toolMapping) int { | |
| 330 | + | name := strings.ToLower(zotName) | |
| 331 | + | server := strings.ToLower(mapping.serverName) | |
| 332 | + | mcpName := strings.ToLower(mapping.mcpTool) | |
| 333 | + | description := strings.ToLower(mapping.description) | |
| 334 | + | score := 0 | |
| 335 | + | matched := 0 | |
| 336 | + | for _, term := range terms { | |
| 337 | + | termScore := 0 | |
| 338 | + | switch { | |
| 339 | + | case term == mcpName || term == name: | |
| 340 | + | termScore = 100 | |
| 341 | + | case strings.Contains(mcpName, term): | |
| 342 | + | termScore = 60 | |
| 343 | + | case strings.Contains(server, term): | |
| 344 | + | termScore = 40 | |
| 345 | + | case strings.Contains(name, term): | |
| 346 | + | termScore = 30 | |
| 347 | + | case strings.Contains(description, term): | |
| 348 | + | termScore = 15 | |
| 349 | + | } | |
| 350 | + | if termScore > 0 { | |
| 351 | + | matched++ | |
| 352 | + | score += termScore | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | if matched == len(terms) { | |
| 356 | + | score += 25 | |
| 357 | + | } | |
| 358 | + | return score | |
| 197 | 359 | } | |
| 198 | 360 | ||
| 199 | 361 | func mcpToolDescription(serverName string, tool mcp.Tool) string { | |
| @@ -24,10 +24,35 @@ import ( | |||||
| 24 | "github.com/patriceckhart/zot/packages/agent/ext" | 24 | "github.com/patriceckhart/zot/packages/agent/ext" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | const ( | |||
| 28 | + | mcpSearchToolName = "mcp__search_tools" | |||
| 29 | + | defaultToolSearchLimit = 8 | |||
| 30 | + | maxToolSearchLimit = 20 | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | var mcpSearchToolSchema = json.RawMessage(`{ | |||
| 34 | + | "type": "object", | |||
| 35 | + | "properties": { | |||
| 36 | + | "query": { | |||
| 37 | + | "type": "string", | |||
| 38 | + | "description": "Words describing the MCP capability needed, such as 'search GitHub code' or 'list n8n workflows'." | |||
| 39 | + | }, | |||
| 40 | + | "limit": { | |||
| 41 | + | "type": "integer", | |||
| 42 | + | "minimum": 1, | |||
| 43 | + | "maximum": 20, | |||
| 44 | + | "description": "Maximum number of matching tools to load. Defaults to 8." | |||
| 45 | + | } | |||
| 46 | + | }, | |||
| 47 | + | "required": ["query"], | |||
| 48 | + | "additionalProperties": false | |||
| 49 | + | }`) | |||
| 50 | + | ||||
| 27 | // toolMapping tracks which zot tool name maps to which MCP server + tool. | 51 | // toolMapping tracks which zot tool name maps to which MCP server + tool. | ||
| 28 | type toolMapping struct { | 52 | type toolMapping struct { | ||
| 29 | - | serverName string // e.g. "filesystem" | 53 | + | serverName string // e.g. "filesystem" |
| 30 | - | mcpTool string // e.g. "read_file" | 54 | + | mcpTool string // e.g. "read_file" |
| 55 | + | description string | |||
| 31 | } | 56 | } | ||
| 32 | 57 | ||||
| 33 | // bridge connects MCP servers to zot's extension protocol. | 58 | // bridge connects MCP servers to zot's extension protocol. | ||
| @@ -42,7 +67,8 @@ type bridge struct { | |||||
| 42 | mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu | 67 | mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu | ||
| 43 | logger *log.Logger | 68 | logger *log.Logger | ||
| 44 | 69 | ||||
| 45 | - | mu sync.Mutex // guards mapping | 70 | + | mu sync.Mutex // guards mapping and searchRegistered |
| 71 | + | searchRegistered bool | |||
| 46 | } | 72 | } | ||
| 47 | 73 | ||||
| 48 | // newBridge creates a new MCP→zot bridge. | 74 | // newBridge creates a new MCP→zot bridge. | ||
| @@ -85,9 +111,30 @@ func (b *bridge) loadServers(cfg Config) { | |||||
| 85 | } | 111 | } | ||
| 86 | } | 112 | } | ||
| 87 | 113 | ||||
| 114 | + | // registerToolSearch exposes the only eager MCP tool definition. The actual | |||
| 115 | + | // MCP tools stay deferred until this local catalog search activates a small, | |||
| 116 | + | // relevant subset. This keeps large MCP installations from bloating every LLM | |||
| 117 | + | // request and is required by providers with tight tool-schema size limits. | |||
| 118 | + | func (b *bridge) registerToolSearch() { | |||
| 119 | + | b.mu.Lock() | |||
| 120 | + | if b.searchRegistered { | |||
| 121 | + | b.mu.Unlock() | |||
| 122 | + | return | |||
| 123 | + | } | |||
| 124 | + | b.searchRegistered = true | |||
| 125 | + | b.mu.Unlock() | |||
| 126 | + | ||||
| 127 | + | b.e.Tool(mcpSearchToolName, | |||
| 128 | + | "Search configured MCP tools by capability and load the matching tool definitions. Use this before calling an MCP tool that is not already available.", | |||
| 129 | + | mcpSearchToolSchema, | |||
| 130 | + | b.searchTools, | |||
| 131 | + | ) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 88 | // registerCachedTools registers previously discovered tool definitions without | 134 | // registerCachedTools registers previously discovered tool definitions without | ||
| 89 | // starting MCP servers. Tool calls still lazy-start the owning server on demand. | 135 | // starting MCP servers. Tool calls still lazy-start the owning server on demand. | ||
| 90 | func (b *bridge) registerCachedTools(cache toolCache) int { | 136 | func (b *bridge) registerCachedTools(cache toolCache) int { | ||
| 137 | + | b.registerToolSearch() | |||
| 91 | count := 0 | 138 | count := 0 | ||
| 92 | for serverName, srv := range b.servers { | 139 | for serverName, srv := range b.servers { | ||
| 93 | cached, ok := cache.Servers[serverName] | 140 | cached, ok := cache.Servers[serverName] | ||
| @@ -186,14 +233,129 @@ func (b *bridge) registerCachedTool(serverName string, tool cachedTool) { | |||||
| 186 | } | 233 | } | ||
| 187 | return | 234 | return | ||
| 188 | } | 235 | } | ||
| 189 | - | b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name} | 236 | + | b.mapping[zotName] = toolMapping{ |
| 237 | + | serverName: serverName, | |||
| 238 | + | mcpTool: tool.Name, | |||
| 239 | + | description: tool.Description, | |||
| 240 | + | } | |||
| 190 | b.mu.Unlock() | 241 | b.mu.Unlock() | ||
| 191 | 242 | ||||
| 192 | - | b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult { | 243 | + | b.e.DeferredTool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult { |
| 193 | return b.handleToolCall(zotName, args) | 244 | return b.handleToolCall(zotName, args) | ||
| 194 | }) | 245 | }) | ||
| 195 | 246 | ||||
| 196 | - | b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name) | 247 | + | b.logger.Printf("registered deferred cached tool: %s → %s/%s", zotName, serverName, tool.Name) |
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | type toolSearchArgs struct { | |||
| 251 | + | Query string `json:"query"` | |||
| 252 | + | Limit int `json:"limit,omitempty"` | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | type toolSearchMatch struct { | |||
| 256 | + | name string | |||
| 257 | + | description string | |||
| 258 | + | score int | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func (b *bridge) searchTools(raw json.RawMessage) ext.ToolResult { | |||
| 262 | + | var args toolSearchArgs | |||
| 263 | + | if err := json.Unmarshal(raw, &args); err != nil { | |||
| 264 | + | return ext.TextErrorResult(mcpSearchToolName + ": invalid arguments: " + err.Error()) | |||
| 265 | + | } | |||
| 266 | + | query := strings.TrimSpace(args.Query) | |||
| 267 | + | if query == "" { | |||
| 268 | + | return ext.TextResult("No MCP tools loaded: provide a non-empty capability query.") | |||
| 269 | + | } | |||
| 270 | + | limit := args.Limit | |||
| 271 | + | if limit == 0 { | |||
| 272 | + | limit = defaultToolSearchLimit | |||
| 273 | + | } | |||
| 274 | + | if limit < 1 || limit > maxToolSearchLimit { | |||
| 275 | + | return ext.TextErrorResult(fmt.Sprintf("%s: limit must be between 1 and %d", mcpSearchToolName, maxToolSearchLimit)) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | matches := b.findTools(query, limit) | |||
| 279 | + | if len(matches) == 0 { | |||
| 280 | + | return ext.TextResult(fmt.Sprintf("No MCP tools matched %q. Try broader capability words or an MCP server name.", query)) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | var text strings.Builder | |||
| 284 | + | fmt.Fprintf(&text, "Loaded %d MCP tool(s) matching %q:\n", len(matches), query) | |||
| 285 | + | activated := make([]string, 0, len(matches)) | |||
| 286 | + | for _, match := range matches { | |||
| 287 | + | activated = append(activated, match.name) | |||
| 288 | + | fmt.Fprintf(&text, "- %s", match.name) | |||
| 289 | + | if match.description != "" { | |||
| 290 | + | fmt.Fprintf(&text, ": %s", match.description) | |||
| 291 | + | } | |||
| 292 | + | text.WriteByte('\n') | |||
| 293 | + | } | |||
| 294 | + | text.WriteString("Call the appropriate loaded tool now. Search again if none fits the task.") | |||
| 295 | + | return ext.ToolResult{ | |||
| 296 | + | Content: []ext.ToolContent{ext.Text(text.String())}, | |||
| 297 | + | ActivateTools: activated, | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | func (b *bridge) findTools(query string, limit int) []toolSearchMatch { | |||
| 302 | + | terms := strings.Fields(strings.ToLower(query)) | |||
| 303 | + | if len(terms) == 0 || limit <= 0 { | |||
| 304 | + | return nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | b.mu.Lock() | |||
| 308 | + | matches := make([]toolSearchMatch, 0, len(b.mapping)) | |||
| 309 | + | for name, mapping := range b.mapping { | |||
| 310 | + | score := scoreToolMatch(terms, name, mapping) | |||
| 311 | + | if score > 0 { | |||
| 312 | + | matches = append(matches, toolSearchMatch{name: name, description: mapping.description, score: score}) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | b.mu.Unlock() | |||
| 316 | + | ||||
| 317 | + | sort.Slice(matches, func(i, j int) bool { | |||
| 318 | + | if matches[i].score != matches[j].score { | |||
| 319 | + | return matches[i].score > matches[j].score | |||
| 320 | + | } | |||
| 321 | + | return matches[i].name < matches[j].name | |||
| 322 | + | }) | |||
| 323 | + | if len(matches) > limit { | |||
| 324 | + | matches = matches[:limit] | |||
| 325 | + | } | |||
| 326 | + | return matches | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func scoreToolMatch(terms []string, zotName string, mapping toolMapping) int { | |||
| 330 | + | name := strings.ToLower(zotName) | |||
| 331 | + | server := strings.ToLower(mapping.serverName) | |||
| 332 | + | mcpName := strings.ToLower(mapping.mcpTool) | |||
| 333 | + | description := strings.ToLower(mapping.description) | |||
| 334 | + | score := 0 | |||
| 335 | + | matched := 0 | |||
| 336 | + | for _, term := range terms { | |||
| 337 | + | termScore := 0 | |||
| 338 | + | switch { | |||
| 339 | + | case term == mcpName || term == name: | |||
| 340 | + | termScore = 100 | |||
| 341 | + | case strings.Contains(mcpName, term): | |||
| 342 | + | termScore = 60 | |||
| 343 | + | case strings.Contains(server, term): | |||
| 344 | + | termScore = 40 | |||
| 345 | + | case strings.Contains(name, term): | |||
| 346 | + | termScore = 30 | |||
| 347 | + | case strings.Contains(description, term): | |||
| 348 | + | termScore = 15 | |||
| 349 | + | } | |||
| 350 | + | if termScore > 0 { | |||
| 351 | + | matched++ | |||
| 352 | + | score += termScore | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | if matched == len(terms) { | |||
| 356 | + | score += 25 | |||
| 357 | + | } | |||
| 358 | + | return score | |||
| 197 | } | 359 | } | ||
| 198 | 360 | ||||
| 199 | func mcpToolDescription(serverName string, tool mcp.Tool) string { | 361 | func mcpToolDescription(serverName string, tool mcp.Tool) string { | ||
A examples/extensions/mcp-bridge/deferred_tools_test.go +269 -0
| @@ -0,0 +1,269 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "encoding/json" | |
| 6 | + | "io" | |
| 7 | + | "log" | |
| 8 | + | "os" | |
| 9 | + | "reflect" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |
| 14 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type bridgeExtensionHarness struct { | |
| 18 | + | ext *ext.Extension | |
| 19 | + | hostW *os.File | |
| 20 | + | frames chan []byte | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newBridgeExtensionHarness(t *testing.T) *bridgeExtensionHarness { | |
| 24 | + | t.Helper() | |
| 25 | + | extIn, hostW, err := os.Pipe() | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | hostR, extOut, err := os.Pipe() | |
| 30 | + | if err != nil { | |
| 31 | + | extIn.Close() | |
| 32 | + | hostW.Close() | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | oldIn, oldOut := os.Stdin, os.Stdout | |
| 37 | + | os.Stdin, os.Stdout = extIn, extOut | |
| 38 | + | e := ext.New("mcp-test", "0.0.0-test") | |
| 39 | + | os.Stdin, os.Stdout = oldIn, oldOut | |
| 40 | + | ||
| 41 | + | h := &bridgeExtensionHarness{ext: e, hostW: hostW, frames: make(chan []byte, 32)} | |
| 42 | + | go func() { | |
| 43 | + | scanner := bufio.NewScanner(hostR) | |
| 44 | + | for scanner.Scan() { | |
| 45 | + | h.frames <- append([]byte(nil), scanner.Bytes()...) | |
| 46 | + | } | |
| 47 | + | close(h.frames) | |
| 48 | + | }() | |
| 49 | + | t.Cleanup(func() { | |
| 50 | + | hostW.Close() | |
| 51 | + | extIn.Close() | |
| 52 | + | extOut.Close() | |
| 53 | + | hostR.Close() | |
| 54 | + | }) | |
| 55 | + | return h | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (h *bridgeExtensionHarness) next(t *testing.T) []byte { | |
| 59 | + | t.Helper() | |
| 60 | + | select { | |
| 61 | + | case frame, ok := <-h.frames: | |
| 62 | + | if !ok { | |
| 63 | + | t.Fatal("extension output closed") | |
| 64 | + | } | |
| 65 | + | return frame | |
| 66 | + | case <-time.After(2 * time.Second): | |
| 67 | + | t.Fatal("timed out waiting for extension frame") | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (h *bridgeExtensionHarness) send(t *testing.T, frame any) { | |
| 73 | + | t.Helper() | |
| 74 | + | data, err := extproto.Encode(frame) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | if _, err := h.hostW.Write(data); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (h *bridgeExtensionHarness) startAndCollectTools(t *testing.T) []extproto.RegisterToolFromExt { | |
| 84 | + | t.Helper() | |
| 85 | + | go func() { _ = h.ext.Run() }() | |
| 86 | + | ||
| 87 | + | var hello extproto.Frame | |
| 88 | + | if err := json.Unmarshal(h.next(t), &hello); err != nil { | |
| 89 | + | t.Fatal(err) | |
| 90 | + | } | |
| 91 | + | if hello.Type != "hello" { | |
| 92 | + | t.Fatalf("first extension frame = %q, want hello", hello.Type) | |
| 93 | + | } | |
| 94 | + | h.send(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion}) | |
| 95 | + | ||
| 96 | + | var tools []extproto.RegisterToolFromExt | |
| 97 | + | for { | |
| 98 | + | frame := h.next(t) | |
| 99 | + | var header extproto.Frame | |
| 100 | + | if err := json.Unmarshal(frame, &header); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | switch header.Type { | |
| 104 | + | case "register_tool": | |
| 105 | + | var tool extproto.RegisterToolFromExt | |
| 106 | + | if err := json.Unmarshal(frame, &tool); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | tools = append(tools, tool) | |
| 110 | + | case "ready": | |
| 111 | + | return tools | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (h *bridgeExtensionHarness) callTool(t *testing.T, id, name, args string) extproto.ToolResultFromExt { | |
| 117 | + | t.Helper() | |
| 118 | + | h.send(t, extproto.ToolCallFromHost{ | |
| 119 | + | Type: "tool_call", | |
| 120 | + | ID: id, | |
| 121 | + | Name: name, | |
| 122 | + | Args: json.RawMessage(args), | |
| 123 | + | }) | |
| 124 | + | for { | |
| 125 | + | frame := h.next(t) | |
| 126 | + | var header extproto.Frame | |
| 127 | + | if err := json.Unmarshal(frame, &header); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | if header.Type != "tool_result" || header.ID != id { | |
| 131 | + | continue | |
| 132 | + | } | |
| 133 | + | var result extproto.ToolResultFromExt | |
| 134 | + | if err := json.Unmarshal(frame, &result); err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | return result | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func cachedDiscoveryFixture(t *testing.T, e *ext.Extension) (*bridge, toolCache) { | |
| 142 | + | t.Helper() | |
| 143 | + | b := newBridge(e, "/project", log.New(io.Discard, "", 0)) | |
| 144 | + | b.loadServers(Config{MCPServers: map[string]ServerConfig{ | |
| 145 | + | "alpha": {Transport: "stdio", Command: "alpha-server"}, | |
| 146 | + | "beta": {Transport: "stdio", Command: "beta-server"}, | |
| 147 | + | }}) | |
| 148 | + | return b, toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{ | |
| 149 | + | "alpha": { | |
| 150 | + | Fingerprint: serverFingerprint(b.servers["alpha"].config), | |
| 151 | + | Tools: []cachedTool{ | |
| 152 | + | {Name: "repo_lookup", Description: "Find repository metadata", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 153 | + | {Name: "issue_scan", Description: "Search repository issues", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 154 | + | {Name: "deploy", Description: "Ship a release", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 155 | + | }, | |
| 156 | + | }, | |
| 157 | + | "beta": { | |
| 158 | + | Fingerprint: serverFingerprint(b.servers["beta"].config), | |
| 159 | + | Tools: []cachedTool{ | |
| 160 | + | {Name: "repository_stats", Description: "Count projects", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 161 | + | {Name: "code_search", Description: "Find source snippets", Schema: json.RawMessage(`{"type":"object"}`)}, | |
| 162 | + | }, | |
| 163 | + | }, | |
| 164 | + | }} | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func TestCachedMCPToolsAreDeferredWithOneActiveLoader(t *testing.T) { | |
| 168 | + | h := newBridgeExtensionHarness(t) | |
| 169 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |
| 170 | + | b.registerToolSearch() | |
| 171 | + | if got := b.registerCachedTools(cache); got != 5 { | |
| 172 | + | t.Fatalf("registerCachedTools() = %d, want 5", got) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | registrations := h.startAndCollectTools(t) | |
| 176 | + | var active []string | |
| 177 | + | deferred := map[string]bool{} | |
| 178 | + | for _, registration := range registrations { | |
| 179 | + | if registration.Deferred { | |
| 180 | + | deferred[registration.Name] = true | |
| 181 | + | } else { | |
| 182 | + | active = append(active, registration.Name) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | wantDeferred := []string{ | |
| 186 | + | "mcp__alpha__deploy", | |
| 187 | + | "mcp__alpha__issue_scan", | |
| 188 | + | "mcp__alpha__repo_lookup", | |
| 189 | + | "mcp__beta__code_search", | |
| 190 | + | "mcp__beta__repository_stats", | |
| 191 | + | } | |
| 192 | + | for _, name := range wantDeferred { | |
| 193 | + | if !deferred[name] { | |
| 194 | + | t.Errorf("cached MCP tool %q was not registered as deferred", name) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | if !reflect.DeepEqual(active, []string{mcpSearchToolName}) { | |
| 198 | + | t.Fatalf("active tools = %v, want only %s", active, mcpSearchToolName) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func TestMCPToolSearchMatchesNameAndDescriptionDeterministically(t *testing.T) { | |
| 203 | + | h := newBridgeExtensionHarness(t) | |
| 204 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |
| 205 | + | b.registerToolSearch() | |
| 206 | + | b.registerCachedTools(cache) | |
| 207 | + | registrations := h.startAndCollectTools(t) | |
| 208 | + | ||
| 209 | + | loader := "" | |
| 210 | + | for _, registration := range registrations { | |
| 211 | + | if !registration.Deferred { | |
| 212 | + | loader = registration.Name | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | if loader == "" { | |
| 216 | + | t.Fatal("no active MCP tool loader registered") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | byName := h.callTool(t, "name", loader, `{"query":"repository_stats"}`) | |
| 220 | + | if byName.IsError || !reflect.DeepEqual(byName.ActivateTools, []string{"mcp__beta__repository_stats"}) { | |
| 221 | + | t.Fatalf("name search result: error=%v activate_tools=%v", byName.IsError, byName.ActivateTools) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | byDescription := h.callTool(t, "description", loader, `{"query":"source snippets"}`) | |
| 225 | + | if byDescription.IsError || !reflect.DeepEqual(byDescription.ActivateTools, []string{"mcp__beta__code_search"}) { | |
| 226 | + | t.Fatalf("description search result: error=%v activate_tools=%v", byDescription.IsError, byDescription.ActivateTools) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | wantLimited := []string{"mcp__beta__repository_stats", "mcp__alpha__issue_scan"} | |
| 230 | + | first := h.callTool(t, "limited-1", loader, `{"query":"repository","limit":2}`) | |
| 231 | + | second := h.callTool(t, "limited-2", loader, `{"query":"repository","limit":2}`) | |
| 232 | + | if first.IsError || !reflect.DeepEqual(first.ActivateTools, wantLimited) { | |
| 233 | + | t.Fatalf("limited search activate_tools = %v, want %v", first.ActivateTools, wantLimited) | |
| 234 | + | } | |
| 235 | + | if !reflect.DeepEqual(second.ActivateTools, first.ActivateTools) { | |
| 236 | + | t.Fatalf("repeated search was not deterministic: first=%v second=%v", first.ActivateTools, second.ActivateTools) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestMCPToolSearchEmptyAndNoMatchAreSafe(t *testing.T) { | |
| 241 | + | h := newBridgeExtensionHarness(t) | |
| 242 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |
| 243 | + | b.registerToolSearch() | |
| 244 | + | b.registerCachedTools(cache) | |
| 245 | + | registrations := h.startAndCollectTools(t) | |
| 246 | + | ||
| 247 | + | loader := "" | |
| 248 | + | for _, registration := range registrations { | |
| 249 | + | if !registration.Deferred { | |
| 250 | + | loader = registration.Name | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | if loader == "" { | |
| 254 | + | t.Fatal("no active MCP tool loader registered") | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | empty := h.callTool(t, "empty", loader, `{}`) | |
| 258 | + | if len(empty.ActivateTools) != 0 { | |
| 259 | + | t.Errorf("empty search activated tools: %v", empty.ActivateTools) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | noMatch := h.callTool(t, "no-match", loader, `{"query":"definitely-not-an-mcp-tool"}`) | |
| 263 | + | if noMatch.IsError { | |
| 264 | + | t.Error("no-match search returned an error") | |
| 265 | + | } | |
| 266 | + | if len(noMatch.ActivateTools) != 0 { | |
| 267 | + | t.Errorf("no-match search activated tools: %v", noMatch.ActivateTools) | |
| 268 | + | } | |
| 269 | + | } | |
| @@ -0,0 +1,269 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "io" | |||
| 7 | + | "log" | |||
| 8 | + | "os" | |||
| 9 | + | "reflect" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/patriceckhart/zot/packages/agent/ext" | |||
| 14 | + | "github.com/patriceckhart/zot/packages/agent/extproto" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type bridgeExtensionHarness struct { | |||
| 18 | + | ext *ext.Extension | |||
| 19 | + | hostW *os.File | |||
| 20 | + | frames chan []byte | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func newBridgeExtensionHarness(t *testing.T) *bridgeExtensionHarness { | |||
| 24 | + | t.Helper() | |||
| 25 | + | extIn, hostW, err := os.Pipe() | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | hostR, extOut, err := os.Pipe() | |||
| 30 | + | if err != nil { | |||
| 31 | + | extIn.Close() | |||
| 32 | + | hostW.Close() | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | oldIn, oldOut := os.Stdin, os.Stdout | |||
| 37 | + | os.Stdin, os.Stdout = extIn, extOut | |||
| 38 | + | e := ext.New("mcp-test", "0.0.0-test") | |||
| 39 | + | os.Stdin, os.Stdout = oldIn, oldOut | |||
| 40 | + | ||||
| 41 | + | h := &bridgeExtensionHarness{ext: e, hostW: hostW, frames: make(chan []byte, 32)} | |||
| 42 | + | go func() { | |||
| 43 | + | scanner := bufio.NewScanner(hostR) | |||
| 44 | + | for scanner.Scan() { | |||
| 45 | + | h.frames <- append([]byte(nil), scanner.Bytes()...) | |||
| 46 | + | } | |||
| 47 | + | close(h.frames) | |||
| 48 | + | }() | |||
| 49 | + | t.Cleanup(func() { | |||
| 50 | + | hostW.Close() | |||
| 51 | + | extIn.Close() | |||
| 52 | + | extOut.Close() | |||
| 53 | + | hostR.Close() | |||
| 54 | + | }) | |||
| 55 | + | return h | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (h *bridgeExtensionHarness) next(t *testing.T) []byte { | |||
| 59 | + | t.Helper() | |||
| 60 | + | select { | |||
| 61 | + | case frame, ok := <-h.frames: | |||
| 62 | + | if !ok { | |||
| 63 | + | t.Fatal("extension output closed") | |||
| 64 | + | } | |||
| 65 | + | return frame | |||
| 66 | + | case <-time.After(2 * time.Second): | |||
| 67 | + | t.Fatal("timed out waiting for extension frame") | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (h *bridgeExtensionHarness) send(t *testing.T, frame any) { | |||
| 73 | + | t.Helper() | |||
| 74 | + | data, err := extproto.Encode(frame) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | if _, err := h.hostW.Write(data); err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (h *bridgeExtensionHarness) startAndCollectTools(t *testing.T) []extproto.RegisterToolFromExt { | |||
| 84 | + | t.Helper() | |||
| 85 | + | go func() { _ = h.ext.Run() }() | |||
| 86 | + | ||||
| 87 | + | var hello extproto.Frame | |||
| 88 | + | if err := json.Unmarshal(h.next(t), &hello); err != nil { | |||
| 89 | + | t.Fatal(err) | |||
| 90 | + | } | |||
| 91 | + | if hello.Type != "hello" { | |||
| 92 | + | t.Fatalf("first extension frame = %q, want hello", hello.Type) | |||
| 93 | + | } | |||
| 94 | + | h.send(t, extproto.HelloAckFromHost{Type: "hello_ack", ProtocolVersion: extproto.ProtocolVersion}) | |||
| 95 | + | ||||
| 96 | + | var tools []extproto.RegisterToolFromExt | |||
| 97 | + | for { | |||
| 98 | + | frame := h.next(t) | |||
| 99 | + | var header extproto.Frame | |||
| 100 | + | if err := json.Unmarshal(frame, &header); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | switch header.Type { | |||
| 104 | + | case "register_tool": | |||
| 105 | + | var tool extproto.RegisterToolFromExt | |||
| 106 | + | if err := json.Unmarshal(frame, &tool); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | tools = append(tools, tool) | |||
| 110 | + | case "ready": | |||
| 111 | + | return tools | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (h *bridgeExtensionHarness) callTool(t *testing.T, id, name, args string) extproto.ToolResultFromExt { | |||
| 117 | + | t.Helper() | |||
| 118 | + | h.send(t, extproto.ToolCallFromHost{ | |||
| 119 | + | Type: "tool_call", | |||
| 120 | + | ID: id, | |||
| 121 | + | Name: name, | |||
| 122 | + | Args: json.RawMessage(args), | |||
| 123 | + | }) | |||
| 124 | + | for { | |||
| 125 | + | frame := h.next(t) | |||
| 126 | + | var header extproto.Frame | |||
| 127 | + | if err := json.Unmarshal(frame, &header); err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | if header.Type != "tool_result" || header.ID != id { | |||
| 131 | + | continue | |||
| 132 | + | } | |||
| 133 | + | var result extproto.ToolResultFromExt | |||
| 134 | + | if err := json.Unmarshal(frame, &result); err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | return result | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func cachedDiscoveryFixture(t *testing.T, e *ext.Extension) (*bridge, toolCache) { | |||
| 142 | + | t.Helper() | |||
| 143 | + | b := newBridge(e, "/project", log.New(io.Discard, "", 0)) | |||
| 144 | + | b.loadServers(Config{MCPServers: map[string]ServerConfig{ | |||
| 145 | + | "alpha": {Transport: "stdio", Command: "alpha-server"}, | |||
| 146 | + | "beta": {Transport: "stdio", Command: "beta-server"}, | |||
| 147 | + | }}) | |||
| 148 | + | return b, toolCache{Version: toolCacheVersion, Servers: map[string]cachedServer{ | |||
| 149 | + | "alpha": { | |||
| 150 | + | Fingerprint: serverFingerprint(b.servers["alpha"].config), | |||
| 151 | + | Tools: []cachedTool{ | |||
| 152 | + | {Name: "repo_lookup", Description: "Find repository metadata", Schema: json.RawMessage(`{"type":"object"}`)}, | |||
| 153 | + | {Name: "issue_scan", Description: "Search repository issues", Schema: json.RawMessage(`{"type":"object"}`)}, | |||
| 154 | + | {Name: "deploy", Description: "Ship a release", Schema: json.RawMessage(`{"type":"object"}`)}, | |||
| 155 | + | }, | |||
| 156 | + | }, | |||
| 157 | + | "beta": { | |||
| 158 | + | Fingerprint: serverFingerprint(b.servers["beta"].config), | |||
| 159 | + | Tools: []cachedTool{ | |||
| 160 | + | {Name: "repository_stats", Description: "Count projects", Schema: json.RawMessage(`{"type":"object"}`)}, | |||
| 161 | + | {Name: "code_search", Description: "Find source snippets", Schema: json.RawMessage(`{"type":"object"}`)}, | |||
| 162 | + | }, | |||
| 163 | + | }, | |||
| 164 | + | }} | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func TestCachedMCPToolsAreDeferredWithOneActiveLoader(t *testing.T) { | |||
| 168 | + | h := newBridgeExtensionHarness(t) | |||
| 169 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |||
| 170 | + | b.registerToolSearch() | |||
| 171 | + | if got := b.registerCachedTools(cache); got != 5 { | |||
| 172 | + | t.Fatalf("registerCachedTools() = %d, want 5", got) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | registrations := h.startAndCollectTools(t) | |||
| 176 | + | var active []string | |||
| 177 | + | deferred := map[string]bool{} | |||
| 178 | + | for _, registration := range registrations { | |||
| 179 | + | if registration.Deferred { | |||
| 180 | + | deferred[registration.Name] = true | |||
| 181 | + | } else { | |||
| 182 | + | active = append(active, registration.Name) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | wantDeferred := []string{ | |||
| 186 | + | "mcp__alpha__deploy", | |||
| 187 | + | "mcp__alpha__issue_scan", | |||
| 188 | + | "mcp__alpha__repo_lookup", | |||
| 189 | + | "mcp__beta__code_search", | |||
| 190 | + | "mcp__beta__repository_stats", | |||
| 191 | + | } | |||
| 192 | + | for _, name := range wantDeferred { | |||
| 193 | + | if !deferred[name] { | |||
| 194 | + | t.Errorf("cached MCP tool %q was not registered as deferred", name) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if !reflect.DeepEqual(active, []string{mcpSearchToolName}) { | |||
| 198 | + | t.Fatalf("active tools = %v, want only %s", active, mcpSearchToolName) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func TestMCPToolSearchMatchesNameAndDescriptionDeterministically(t *testing.T) { | |||
| 203 | + | h := newBridgeExtensionHarness(t) | |||
| 204 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |||
| 205 | + | b.registerToolSearch() | |||
| 206 | + | b.registerCachedTools(cache) | |||
| 207 | + | registrations := h.startAndCollectTools(t) | |||
| 208 | + | ||||
| 209 | + | loader := "" | |||
| 210 | + | for _, registration := range registrations { | |||
| 211 | + | if !registration.Deferred { | |||
| 212 | + | loader = registration.Name | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | if loader == "" { | |||
| 216 | + | t.Fatal("no active MCP tool loader registered") | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | byName := h.callTool(t, "name", loader, `{"query":"repository_stats"}`) | |||
| 220 | + | if byName.IsError || !reflect.DeepEqual(byName.ActivateTools, []string{"mcp__beta__repository_stats"}) { | |||
| 221 | + | t.Fatalf("name search result: error=%v activate_tools=%v", byName.IsError, byName.ActivateTools) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | byDescription := h.callTool(t, "description", loader, `{"query":"source snippets"}`) | |||
| 225 | + | if byDescription.IsError || !reflect.DeepEqual(byDescription.ActivateTools, []string{"mcp__beta__code_search"}) { | |||
| 226 | + | t.Fatalf("description search result: error=%v activate_tools=%v", byDescription.IsError, byDescription.ActivateTools) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | wantLimited := []string{"mcp__beta__repository_stats", "mcp__alpha__issue_scan"} | |||
| 230 | + | first := h.callTool(t, "limited-1", loader, `{"query":"repository","limit":2}`) | |||
| 231 | + | second := h.callTool(t, "limited-2", loader, `{"query":"repository","limit":2}`) | |||
| 232 | + | if first.IsError || !reflect.DeepEqual(first.ActivateTools, wantLimited) { | |||
| 233 | + | t.Fatalf("limited search activate_tools = %v, want %v", first.ActivateTools, wantLimited) | |||
| 234 | + | } | |||
| 235 | + | if !reflect.DeepEqual(second.ActivateTools, first.ActivateTools) { | |||
| 236 | + | t.Fatalf("repeated search was not deterministic: first=%v second=%v", first.ActivateTools, second.ActivateTools) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestMCPToolSearchEmptyAndNoMatchAreSafe(t *testing.T) { | |||
| 241 | + | h := newBridgeExtensionHarness(t) | |||
| 242 | + | b, cache := cachedDiscoveryFixture(t, h.ext) | |||
| 243 | + | b.registerToolSearch() | |||
| 244 | + | b.registerCachedTools(cache) | |||
| 245 | + | registrations := h.startAndCollectTools(t) | |||
| 246 | + | ||||
| 247 | + | loader := "" | |||
| 248 | + | for _, registration := range registrations { | |||
| 249 | + | if !registration.Deferred { | |||
| 250 | + | loader = registration.Name | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | if loader == "" { | |||
| 254 | + | t.Fatal("no active MCP tool loader registered") | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | empty := h.callTool(t, "empty", loader, `{}`) | |||
| 258 | + | if len(empty.ActivateTools) != 0 { | |||
| 259 | + | t.Errorf("empty search activated tools: %v", empty.ActivateTools) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | noMatch := h.callTool(t, "no-match", loader, `{"query":"definitely-not-an-mcp-tool"}`) | |||
| 263 | + | if noMatch.IsError { | |||
| 264 | + | t.Error("no-match search returned an error") | |||
| 265 | + | } | |||
| 266 | + | if len(noMatch.ActivateTools) != 0 { | |||
| 267 | + | t.Errorf("no-match search activated tools: %v", noMatch.ActivateTools) | |||
| 268 | + | } | |||
| 269 | + | } | |||
M examples/extensions/mcp-bridge/extension.json +2 -2
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "mcp", | |
| 3 | - | "version": "1.0.0", | |
| 3 | + | "version": "1.1.0", | |
| 4 | 4 | "exec": "./mcp-bridge", | |
| 5 | 5 | "language": "go", | |
| 6 | - | "description": "Connect zot to MCP servers - tools are registered automatically", | |
| 6 | + | "description": "Connect zot to MCP servers with on-demand tool discovery", | |
| 7 | 7 | "enabled": true | |
| 8 | 8 | } | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | "name": "mcp", | 2 | "name": "mcp", | ||
| 3 | - | "version": "1.0.0", | 3 | + | "version": "1.1.0", |
| 4 | "exec": "./mcp-bridge", | 4 | "exec": "./mcp-bridge", | ||
| 5 | "language": "go", | 5 | "language": "go", | ||
| 6 | - | "description": "Connect zot to MCP servers - tools are registered automatically", | 6 | + | "description": "Connect zot to MCP servers with on-demand tool discovery", |
| 7 | "enabled": true | 7 | "enabled": true | ||
| 8 | } | 8 | } | ||
M examples/extensions/mcp-bridge/main.go +2 -1
| @@ -45,7 +45,7 @@ import ( | |||
| 45 | 45 | ) | |
| 46 | 46 | ||
| 47 | 47 | func main() { | |
| 48 | - | e := ext.New("mcp", "1.0.0") | |
| 48 | + | e := ext.New("mcp", "1.1.0") | |
| 49 | 49 | ||
| 50 | 50 | // Logger writes to stderr (captured by zot into ext logs) | |
| 51 | 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | |
| @@ -78,6 +78,7 @@ func main() { | |||
| 78 | 78 | ||
| 79 | 79 | b = newBridge(e, cwd, logger) | |
| 80 | 80 | b.loadServers(cfg) | |
| 81 | + | b.registerToolSearch() | |
| 81 | 82 | ||
| 82 | 83 | cachePath := toolCachePath() | |
| 83 | 84 | cache, err := readToolCache(cachePath) | |
| @@ -45,7 +45,7 @@ import ( | |||||
| 45 | ) | 45 | ) | ||
| 46 | 46 | ||||
| 47 | func main() { | 47 | func main() { | ||
| 48 | - | e := ext.New("mcp", "1.0.0") | 48 | + | e := ext.New("mcp", "1.1.0") |
| 49 | 49 | ||||
| 50 | // Logger writes to stderr (captured by zot into ext logs) | 50 | // Logger writes to stderr (captured by zot into ext logs) | ||
| 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | 51 | logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags) | ||
| @@ -78,6 +78,7 @@ func main() { | |||||
| 78 | 78 | ||||
| 79 | b = newBridge(e, cwd, logger) | 79 | b = newBridge(e, cwd, logger) | ||
| 80 | b.loadServers(cfg) | 80 | b.loadServers(cfg) | ||
| 81 | + | b.registerToolSearch() | |||
| 81 | 82 | ||||
| 82 | cachePath := toolCachePath() | 83 | cachePath := toolCachePath() | ||
| 83 | cache, err := readToolCache(cachePath) | 84 | cache, err := readToolCache(cachePath) | ||