detached 54d7166

Revert "Load MCP tool schemas on demand"

This reverts commit b777b5aa0cf74c5d7d1f6519c4ea5b23c8eb6c87.
commit
54d716659b13b304d9896d342e1b3ea505f6517a
author
mi-skam · · 2026-07-21 15:57 UTC
parent
4eae592
signature
SSH UnverifiedSHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0w The key is unknown
5 files · +12 -447
M examples/extensions/mcp-bridge/README.md +3 -6
@@ -7,9 +7,8 @@ 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-- **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
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
1312 - **Multi-transport** — stdio, streamable-http, and SSE transports
1413 - **Multi-server** — connect to any number of MCP servers simultaneously
1514 - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions
@@ -54,9 +53,7 @@ This extension reads MCP server configurations from standard locations (same for
5453 zot ext install .
5554 ```
5655
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.
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.
6057
6158 ## Configuration
6259
@@ -7,9 +7,8 @@ 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-- **On-demand tool discovery** — only `mcp__search_tools` is advertised initially; matching MCP schemas load when needed instead of bloating every model request10+- **Smart lazy loading** — cached tools register at startup, servers wake for refresh or tool calls, then auto-sleep after idle time
11-- **Smart lazy loading** — cached definitions register as deferred tools at startup, servers wake for refresh or tool calls, then auto-sleep after idle time11+- **Auto-respawn** — calling a tool on a sleeping server wakes it up automatically
12-- **Auto-respawn** — calling a loaded tool on a sleeping server wakes it up automatically
13 - **Multi-transport** — stdio, streamable-http, and SSE transports12 - **Multi-transport** — stdio, streamable-http, and SSE transports
14 - **Multi-server** — connect to any number of MCP servers simultaneously13 - **Multi-server** — connect to any number of MCP servers simultaneously
15 - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions14 - **Tool namespacing** — tools appear as `mcp__<server>__<tool>` to avoid collisions
@@ -54,9 +53,7 @@ This extension reads MCP server configurations from standard locations (same for
54 zot ext install .53 zot ext install .
55 ```54 ```
56 55
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.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.
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.
60 57
61 ## Configuration58 ## Configuration
62 59
M examples/extensions/mcp-bridge/bridge.go +6 -168
@@ -24,35 +24,10 @@ 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-
5127 // toolMapping tracks which zot tool name maps to which MCP server + tool.
5228 type toolMapping struct {
53- serverName string // e.g. "filesystem"
54- mcpTool string // e.g. "read_file"
55- description string
29+ serverName string // e.g. "filesystem"
30+ mcpTool string // e.g. "read_file"
5631 }
5732
5833 // bridge connects MCP servers to zot's extension protocol.
@@ -67,8 +42,7 @@ type bridge struct {
6742 mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
6843 logger *log.Logger
6944
70- mu sync.Mutex // guards mapping and searchRegistered
71- searchRegistered bool
45+ mu sync.Mutex // guards mapping
7246 }
7347
7448 // newBridge creates a new MCP→zot bridge.
@@ -111,30 +85,9 @@ func (b *bridge) loadServers(cfg Config) {
11185 }
11286 }
11387
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-
13488 // registerCachedTools registers previously discovered tool definitions without
13589 // starting MCP servers. Tool calls still lazy-start the owning server on demand.
13690 func (b *bridge) registerCachedTools(cache toolCache) int {
137- b.registerToolSearch()
13891 count := 0
13992 for serverName, srv := range b.servers {
14093 cached, ok := cache.Servers[serverName]
@@ -233,129 +186,14 @@ func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
233186 }
234187 return
235188 }
236- b.mapping[zotName] = toolMapping{
237- serverName: serverName,
238- mcpTool: tool.Name,
239- description: tool.Description,
240- }
189+ b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name}
241190 b.mu.Unlock()
242191
243- b.e.DeferredTool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
192+ b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
244193 return b.handleToolCall(zotName, args)
245194 })
246195
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
196+ b.logger.Printf("registered cached tool: %s → %s/%s", zotName, serverName, tool.Name)
359197 }
360198
361199 func mcpToolDescription(serverName string, tool mcp.Tool) string {
@@ -24,35 +24,10 @@ 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-
51 // toolMapping tracks which zot tool name maps to which MCP server + tool.27 // toolMapping tracks which zot tool name maps to which MCP server + tool.
52 type toolMapping struct {28 type toolMapping struct {
53- serverName string // e.g. "filesystem"29+ serverName string // e.g. "filesystem"
54- mcpTool string // e.g. "read_file"30+ mcpTool string // e.g. "read_file"
55- description string
56 }31 }
57 32
58 // bridge connects MCP servers to zot's extension protocol.33 // bridge connects MCP servers to zot's extension protocol.
@@ -67,8 +42,7 @@ type bridge struct {
67 mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu42 mapping map[string]toolMapping // zot tool name → MCP server + tool; guarded by mu
68 logger *log.Logger43 logger *log.Logger
69 44
70- mu sync.Mutex // guards mapping and searchRegistered45+ mu sync.Mutex // guards mapping
71- searchRegistered bool
72 }46 }
73 47
74 // newBridge creates a new MCP→zot bridge.48 // newBridge creates a new MCP→zot bridge.
@@ -111,30 +85,9 @@ func (b *bridge) loadServers(cfg Config) {
111 }85 }
112 }86 }
113 87
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-
134 // registerCachedTools registers previously discovered tool definitions without88 // registerCachedTools registers previously discovered tool definitions without
135 // starting MCP servers. Tool calls still lazy-start the owning server on demand.89 // starting MCP servers. Tool calls still lazy-start the owning server on demand.
136 func (b *bridge) registerCachedTools(cache toolCache) int {90 func (b *bridge) registerCachedTools(cache toolCache) int {
137- b.registerToolSearch()
138 count := 091 count := 0
139 for serverName, srv := range b.servers {92 for serverName, srv := range b.servers {
140 cached, ok := cache.Servers[serverName]93 cached, ok := cache.Servers[serverName]
@@ -233,129 +186,14 @@ func (b *bridge) registerCachedTool(serverName string, tool cachedTool) {
233 }186 }
234 return187 return
235 }188 }
236- b.mapping[zotName] = toolMapping{189+ b.mapping[zotName] = toolMapping{serverName: serverName, mcpTool: tool.Name}
237- serverName: serverName,
238- mcpTool: tool.Name,
239- description: tool.Description,
240- }
241 b.mu.Unlock()190 b.mu.Unlock()
242 191
243- b.e.DeferredTool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {192+ b.e.Tool(zotName, tool.Description, json.RawMessage(tool.Schema), func(args json.RawMessage) ext.ToolResult {
244 return b.handleToolCall(zotName, args)193 return b.handleToolCall(zotName, args)
245 })194 })
246 195
247- b.logger.Printf("registered deferred cached tool: %s → %s/%s", zotName, serverName, tool.Name)196+ b.logger.Printf("registered 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
359 }197 }
360 198
361 func mcpToolDescription(serverName string, tool mcp.Tool) string {199 func mcpToolDescription(serverName string, tool mcp.Tool) string {
D examples/extensions/mcp-bridge/deferred_tools_test.go +0 -269
@@ -1,269 +0,0 @@
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-}
@@ -1,269 +0,0 @@
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.1.0",
3+ "version": "1.0.0",
44 "exec": "./mcp-bridge",
55 "language": "go",
6- "description": "Connect zot to MCP servers with on-demand tool discovery",
6+ "description": "Connect zot to MCP servers - tools are registered automatically",
77 "enabled": true
88 }
@@ -1,8 +1,8 @@
1 {1 {
2 "name": "mcp",2 "name": "mcp",
3- "version": "1.1.0",3+ "version": "1.0.0",
4 "exec": "./mcp-bridge",4 "exec": "./mcp-bridge",
5 "language": "go",5 "language": "go",
6- "description": "Connect zot to MCP servers with on-demand tool discovery",6+ "description": "Connect zot to MCP servers - tools are registered automatically",
7 "enabled": true7 "enabled": true
8 }8 }
M examples/extensions/mcp-bridge/main.go +1 -2
@@ -45,7 +45,7 @@ import (
4545 )
4646
4747 func main() {
48- e := ext.New("mcp", "1.1.0")
48+ e := ext.New("mcp", "1.0.0")
4949
5050 // Logger writes to stderr (captured by zot into ext logs)
5151 logger := log.New(os.Stderr, "[mcp-bridge] ", log.LstdFlags)
@@ -78,7 +78,6 @@ func main() {
7878
7979 b = newBridge(e, cwd, logger)
8080 b.loadServers(cfg)
81- b.registerToolSearch()
8281
8382 cachePath := toolCachePath()
8483 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.1.0")48+ e := ext.New("mcp", "1.0.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,7 +78,6 @@ 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()
82 81
83 cachePath := toolCachePath()82 cachePath := toolCachePath()
84 cache, err := readToolCache(cachePath)83 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 477

↑↓ moveenter openesc close