detached 103a28f

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
103a28fa4fbbc825843a33956b7927e0367dd44f
author
mi-skam · · 2026-07-21 15:55 UTC
committer
mi-skam · · 2026-07-21 15:56 UTC
parent
7e6033a
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The 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
77 ## Features
88
99 - **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
1213 - **Multi-transport** — stdio, streamable-http, and SSE transports
1314 - **Multi-server** — connect to any number of MCP servers simultaneously
1415 - **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
5354 zot ext install .
5455 ```
5556
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.
5760
5861 ## Configuration
5962
@@ -7,8 +7,9 @@ This extension reads MCP server configurations from standard locations (same for
7 ## Features7 ## Features
8 8
9 - **Standard config format** — same JSON as Claude Desktop, Cursor, Cline9 - **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 time10+- **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 automatically11+- **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 transports13 - **Multi-transport** — stdio, streamable-http, and SSE transports
13 - **Multi-server** — connect to any number of MCP servers simultaneously14 - **Multi-server** — connect to any number of MCP servers simultaneously
14 - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions15 - **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 ## Configuration61 ## Configuration
59 62
M examples/extensions/mcp-bridge/bridge.go +168 -6
@@ -24,10 +24,35 @@ import (
2424 "github.com/patriceckhart/zot/packages/agent/ext"
2525 )
2626
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+
2751 // toolMapping tracks which zot tool name maps to which MCP server + tool.
2852 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
3156 }
3257
3358 // bridge connects MCP servers to zot's extension protocol.
@@ -42,7 +67,8 @@ type bridge struct {
4267 mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
4368 logger *log.Logger
4469
45- mu sync.Mutex // guards mapping
70+ mu sync.Mutex // guards mapping and searchRegistered
71+ searchRegistered bool
4672 }
4773
4874 // newBridge creates a new MCP→zot bridge.
@@ -85,9 +111,30 @@ func (b *bridge) loadServers(cfg Config) {
85111 }
86112 }
87113
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+
88134 // registerCachedTools registers previously discovered tool definitions without
89135 // starting MCP servers. Tool calls still lazy-start the owning server on demand.
90136 func (b *bridge) registerCachedTools(cache toolCache) int {
137+ b.registerToolSearch()
91138 count := 0
92139 for serverName, srv := range b.servers {
93140 cached, ok := cache.Servers[serverName]
@@ -186,14 +233,129 @@ func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
186233 }
187234 return
188235 }
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+ }
190241 b.mu.Unlock()
191242
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 {
193244 return b.handleToolCall(zotName, args)
194245 })
195246
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
197359 }
198360
199361 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 mu67 mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
43 logger *log.Logger68 logger *log.Logger
44 69
45- mu sync.Mutex // guards mapping70+ 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 without134 // 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 := 0138 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 return234 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 @@
11 {
22 "name": "mcp",
3- "version": "1.0.0",
3+ "version": "1.1.0",
44 "exec": "./mcp-bridge",
55 "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",
77 "enabled": true
88 }
@@ -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": true7 "enabled": true
8 }8 }
M examples/extensions/mcp-bridge/main.go +2 -1
@@ -45,7 +45,7 @@ import (
4545 )
4646
4747 func main() {
48- e := ext.New("mcp", "1.0.0")
48+ e := ext.New("mcp", "1.1.0")
4949
5050 // Logger writes to stderr (captured by zot into ext logs)
5151 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)
@@ -78,6 +78,7 @@ func main() {
7878
7979 b = newBridge(e, cwd, logger)
8080 b.loadServers(cfg)
81+ b.registerToolSearch()
8182
8283 cachePath := toolCachePath()
8384 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close