detached a11290d

Merge pull request #92 from mi-skam/feat/mcp-bridge-1.1.0

Load MCP tool schemas on demand
commit
a11290d01fa74a9054c50b5ebbd59f53fcef1493
author
Patric Eckhart · · 2026-07-21 16:33 UTC
committer
GitHub · · 2026-07-21 16:33 UTC
merge of
b3586dd + 8f1edc1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.96
12 files · +570 -43
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)
M packages/provider/amazon_bedrock.go +3 -2
@@ -411,11 +411,12 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
411411 }
412412 out.Messages = append(out.Messages, bm)
413413 }
414- if len(req.Tools) > 0 {
414+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
415+ if len(activeTools) > 0 {
415416 tc := struct {
416417 Tools []bedrockToolSpec `json:"tools"`
417418 }{}
418- for _, t := range req.Tools {
419+ for _, t := range activeTools {
419420 var ts bedrockToolSpec
420421 ts.ToolSpec.Name = t.Name
421422 ts.ToolSpec.Description = t.Description
@@ -411,11 +411,12 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
411 }411 }
412 out.Messages = append(out.Messages, bm)412 out.Messages = append(out.Messages, bm)
413 }413 }
414- if len(req.Tools) > 0 {414+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
415+ if len(activeTools) > 0 {
415 tc := struct {416 tc := struct {
416 Tools []bedrockToolSpec `json:"tools"`417 Tools []bedrockToolSpec `json:"tools"`
417 }{}418 }{}
418- for _, t := range req.Tools {419+ for _, t := range activeTools {
419 var ts bedrockToolSpec420 var ts bedrockToolSpec
420 ts.ToolSpec.Name = t.Name421 ts.ToolSpec.Name = t.Name
421 ts.ToolSpec.Description = t.Description422 ts.ToolSpec.Description = t.Description
M packages/provider/anthropic.go +1 -1
@@ -312,7 +312,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
312312 }
313313 }
314314
315- for _, t := range req.Tools {
315+ for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
316316 name := t.Name
317317 if c.oauthTok != "" {
318318 name = toClaudeCodeToolName(name)
@@ -312,7 +312,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
312 }312 }
313 }313 }
314 314
315- for _, t := range req.Tools {315+ for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
316 name := t.Name316 name := t.Name
317 if c.oauthTok != "" {317 if c.oauthTok != "" {
318 name = toClaudeCodeToolName(name)318 name = toClaudeCodeToolName(name)
M packages/provider/deferred_tools_test.go +87 -17
@@ -38,29 +38,99 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
3838 }
3939
4040 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41- client := NewOpenAI("token", "").(*openaiClient)
4241 tools := []Tool{
4342 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
4443 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
4544 }
46- wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})
47- if err != nil {
48- t.Fatal(err)
49- }
50- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {
51- t.Fatalf("initial tools = %+v", wire.Tools)
45+ builders := map[string]func(Request) ([]string, error){
46+ "openai": func(req Request) ([]string, error) {
47+ wire, err := NewOpenAI("token", "").(*openaiClient).buildRequest(req)
48+ if err != nil {
49+ return nil, err
50+ }
51+ names := make([]string, 0, len(wire.Tools))
52+ for _, tool := range wire.Tools {
53+ names = append(names, tool.Function.Name)
54+ }
55+ return names, nil
56+ },
57+ "anthropic": func(req Request) ([]string, error) {
58+ req.Model = "claude-sonnet-4-6"
59+ wire, err := NewAnthropic("token", "").(*anthropicClient).buildRequest(req)
60+ if err != nil {
61+ return nil, err
62+ }
63+ names := make([]string, 0, len(wire.Tools))
64+ for _, tool := range wire.Tools {
65+ names = append(names, tool.Name)
66+ }
67+ return names, nil
68+ },
69+ "bedrock": func(req Request) ([]string, error) {
70+ req.Model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
71+ wire, err := (&bedrockClient{region: "us-east-1"}).buildRequest(req)
72+ if err != nil {
73+ return nil, err
74+ }
75+ if wire.ToolConfig == nil {
76+ return nil, nil
77+ }
78+ names := make([]string, 0, len(wire.ToolConfig.Tools))
79+ for _, tool := range wire.ToolConfig.Tools {
80+ names = append(names, tool.ToolSpec.Name)
81+ }
82+ return names, nil
83+ },
84+ "gemini": func(req Request) ([]string, error) {
85+ req.Model = "gemini-2.5-pro"
86+ wire, _, err := NewGemini("token", "").(*geminiClient).buildRequest(req)
87+ if err != nil {
88+ return nil, err
89+ }
90+ var names []string
91+ for _, group := range wire.Tools {
92+ for _, tool := range group.FunctionDeclarations {
93+ names = append(names, tool.Name)
94+ }
95+ }
96+ return names, nil
97+ },
98+ "openai-codex": func(req Request) ([]string, error) {
99+ req.Model = "gpt-5.5"
100+ wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
101+ if err != nil {
102+ return nil, err
103+ }
104+ names := make([]string, 0, len(wire.Tools))
105+ for _, tool := range wire.Tools {
106+ names = append(names, tool.Name)
107+ }
108+ return names, nil
109+ },
52110 }
53111
54- wire, err = client.buildRequest(Request{
55- Model: "gpt-5",
56- Tools: tools,
57- Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
58- })
59- if err != nil {
60- t.Fatal(err)
61- }
62- if len(wire.Tools) != 2 {
63- t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)
112+ for name, build := range builders {
113+ t.Run(name, func(t *testing.T) {
114+ initial, err := build(Request{Model: "gpt-5", Tools: tools})
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if len(initial) != 1 || initial[0] != "base" {
119+ t.Fatalf("initial tools = %v, want [base]", initial)
120+ }
121+
122+ activated, err := build(Request{
123+ Model: "gpt-5",
124+ Tools: tools,
125+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if len(activated) != 2 || activated[0] != "base" || activated[1] != "late" {
131+ t.Fatalf("activated tools = %v, want [base late]", activated)
132+ }
133+ })
64134 }
65135 }
66136
@@ -38,29 +38,99 @@ func TestKimiK3DeferredToolsLoadAtToolResult(t *testing.T) {
38 }38 }
39 39
40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {40 func TestDeferredToolsFallbackToActiveTopLevelDefinitions(t *testing.T) {
41- client := NewOpenAI("token", "").(*openaiClient)
42 tools := []Tool{41 tools := []Tool{
43 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},42 {Name: "base", Schema: json.RawMessage(`{"type":"object"}`)},
44 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},43 {Name: "late", Schema: json.RawMessage(`{"type":"object"}`), Deferred: true},
45 }44 }
46- wire, err := client.buildRequest(Request{Model: "gpt-5", Tools: tools})45+ builders := map[string]func(Request) ([]string, error){
47- if err != nil {46+ "openai": func(req Request) ([]string, error) {
48- t.Fatal(err)47+ wire, err := NewOpenAI("token", "").(*openaiClient).buildRequest(req)
49- }48+ if err != nil {
50- if len(wire.Tools) != 1 || wire.Tools[0].Function.Name != "base" {49+ return nil, err
51- t.Fatalf("initial tools = %+v", wire.Tools)50+ }
51+ names := make([]string, 0, len(wire.Tools))
52+ for _, tool := range wire.Tools {
53+ names = append(names, tool.Function.Name)
54+ }
55+ return names, nil
56+ },
57+ "anthropic": func(req Request) ([]string, error) {
58+ req.Model = "claude-sonnet-4-6"
59+ wire, err := NewAnthropic("token", "").(*anthropicClient).buildRequest(req)
60+ if err != nil {
61+ return nil, err
62+ }
63+ names := make([]string, 0, len(wire.Tools))
64+ for _, tool := range wire.Tools {
65+ names = append(names, tool.Name)
66+ }
67+ return names, nil
68+ },
69+ "bedrock": func(req Request) ([]string, error) {
70+ req.Model = "anthropic.claude-sonnet-4-5-20250929-v1:0"
71+ wire, err := (&bedrockClient{region: "us-east-1"}).buildRequest(req)
72+ if err != nil {
73+ return nil, err
74+ }
75+ if wire.ToolConfig == nil {
76+ return nil, nil
77+ }
78+ names := make([]string, 0, len(wire.ToolConfig.Tools))
79+ for _, tool := range wire.ToolConfig.Tools {
80+ names = append(names, tool.ToolSpec.Name)
81+ }
82+ return names, nil
83+ },
84+ "gemini": func(req Request) ([]string, error) {
85+ req.Model = "gemini-2.5-pro"
86+ wire, _, err := NewGemini("token", "").(*geminiClient).buildRequest(req)
87+ if err != nil {
88+ return nil, err
89+ }
90+ var names []string
91+ for _, group := range wire.Tools {
92+ for _, tool := range group.FunctionDeclarations {
93+ names = append(names, tool.Name)
94+ }
95+ }
96+ return names, nil
97+ },
98+ "openai-codex": func(req Request) ([]string, error) {
99+ req.Model = "gpt-5.5"
100+ wire, err := NewOpenAICodex("token", "account", "").(*codexClient).buildRequest(req)
101+ if err != nil {
102+ return nil, err
103+ }
104+ names := make([]string, 0, len(wire.Tools))
105+ for _, tool := range wire.Tools {
106+ names = append(names, tool.Name)
107+ }
108+ return names, nil
109+ },
52 }110 }
53 111
54- wire, err = client.buildRequest(Request{112+ for name, build := range builders {
55- Model: "gpt-5",113+ t.Run(name, func(t *testing.T) {
56- Tools: tools,114+ initial, err := build(Request{Model: "gpt-5", Tools: tools})
57- Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},115+ if err != nil {
58- })116+ t.Fatal(err)
59- if err != nil {117+ }
60- t.Fatal(err)118+ if len(initial) != 1 || initial[0] != "base" {
61- }119+ t.Fatalf("initial tools = %v, want [base]", initial)
62- if len(wire.Tools) != 2 {120+ }
63- t.Fatalf("activated tools = %+v, want both definitions", wire.Tools)121+
122+ activated, err := build(Request{
123+ Model: "gpt-5",
124+ Tools: tools,
125+ Messages: []Message{{Role: RoleTool, AddedToolNames: []string{"late"}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if len(activated) != 2 || activated[0] != "base" || activated[1] != "late" {
131+ t.Fatalf("activated tools = %v, want [base late]", activated)
132+ }
133+ })
64 }134 }
65 }135 }
66 136
M packages/provider/gemini.go +4 -3
@@ -151,13 +151,14 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
151151 }
152152
153153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
154+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
154155
155156 // Convert tool defs. Gemini image-generation models reject function
156157 // declarations with "Function calling is not enabled for this model";
157158 // for those models, send a direct multimodal prompt instead.
158- if functionsEnabled && len(req.Tools) > 0 {
159- decls := make([]gemFunctionDecl, 0, len(req.Tools))
160- for _, t := range req.Tools {
159+ if functionsEnabled && len(activeTools) > 0 {
160+ decls := make([]gemFunctionDecl, 0, len(activeTools))
161+ for _, t := range activeTools {
161162 schema := sanitizeGeminiToolSchema(t.Schema)
162163 decls = append(decls, gemFunctionDecl{
163164 Name: t.Name,
@@ -151,13 +151,14 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
151 }151 }
152 152
153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)153 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
154+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
154 155
155 // Convert tool defs. Gemini image-generation models reject function156 // Convert tool defs. Gemini image-generation models reject function
156 // declarations with "Function calling is not enabled for this model";157 // declarations with "Function calling is not enabled for this model";
157 // for those models, send a direct multimodal prompt instead.158 // for those models, send a direct multimodal prompt instead.
158- if functionsEnabled && len(req.Tools) > 0 {159+ if functionsEnabled && len(activeTools) > 0 {
159- decls := make([]gemFunctionDecl, 0, len(req.Tools))160+ decls := make([]gemFunctionDecl, 0, len(activeTools))
160- for _, t := range req.Tools {161+ for _, t := range activeTools {
161 schema := sanitizeGeminiToolSchema(t.Schema)162 schema := sanitizeGeminiToolSchema(t.Schema)
162 decls = append(decls, gemFunctionDecl{163 decls = append(decls, gemFunctionDecl{
163 Name: t.Name,164 Name: t.Name,
M packages/provider/openai.go +1 -6
@@ -270,15 +270,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
270270
271271 deferredMode := isKimiDeferredModel(req.Model)
272272 toolByName := make(map[string]Tool, len(req.Tools))
273- activatedTools := make(map[string]bool)
274273 for _, t := range req.Tools {
275274 toolByName[t.Name] = t
276275 }
277- for _, msg := range req.Messages {
278- for _, name := range msg.AddedToolNames {
279- activatedTools[name] = true
280- }
281- }
276+ activatedTools := activatedToolNames(req.Messages)
282277
283278 // DeepSeek's chat-completions API rejects the multimodal content
284279 // schema (parts arrays containing image_url). Force every user/tool
@@ -270,15 +270,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
270 270
271 deferredMode := isKimiDeferredModel(req.Model)271 deferredMode := isKimiDeferredModel(req.Model)
272 toolByName := make(map[string]Tool, len(req.Tools))272 toolByName := make(map[string]Tool, len(req.Tools))
273- activatedTools := make(map[string]bool)
274 for _, t := range req.Tools {273 for _, t := range req.Tools {
275 toolByName[t.Name] = t274 toolByName[t.Name] = t
276 }275 }
277- for _, msg := range req.Messages {276+ activatedTools := activatedToolNames(req.Messages)
278- for _, name := range msg.AddedToolNames {
279- activatedTools[name] = true
280- }
281- }
282 277
283 // DeepSeek's chat-completions API rejects the multimodal content278 // DeepSeek's chat-completions API rejects the multimodal content
284 // schema (parts arrays containing image_url). Force every user/tool279 // schema (parts arrays containing image_url). Force every user/tool
M packages/provider/openai_codex.go +3 -2
@@ -191,9 +191,10 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
191191 body.Reasoning = &codexReasoningConfig{Effort: effort}
192192 }
193193 }
194- if len(req.Tools) > 0 {
194+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
195+ if len(activeTools) > 0 {
195196 body.ToolChoice = "auto"
196- for _, t := range req.Tools {
197+ for _, t := range activeTools {
197198 params := t.Schema
198199 if len(params) == 0 {
199200 params = json.RawMessage(`{"type":"object","properties":{}}`)
@@ -191,9 +191,10 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
191 body.Reasoning = &codexReasoningConfig{Effort: effort}191 body.Reasoning = &codexReasoningConfig{Effort: effort}
192 }192 }
193 }193 }
194- if len(req.Tools) > 0 {194+ activeTools := activeToolDefinitions(req.Tools, req.Messages)
195+ if len(activeTools) > 0 {
195 body.ToolChoice = "auto"196 body.ToolChoice = "auto"
196- for _, t := range req.Tools {197+ for _, t := range activeTools {
197 params := t.Schema198 params := t.Schema
198 if len(params) == 0 {199 if len(params) == 0 {
199 params = json.RawMessage(`{"type":"object","properties":{}}`)200 params = json.RawMessage(`{"type":"object","properties":{}}`)
M packages/provider/provider.go +24 -0
@@ -130,6 +130,30 @@ type Tool struct {
130130 Deferred bool `json:"deferred,omitempty"`
131131 }
132132
133+// activeToolDefinitions returns eager tools plus deferred tools activated by
134+// prior tool results. Providers with a native position-sensitive deferred-tool
135+// format can use the activation markers directly instead.
136+func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
137+ activated := activatedToolNames(messages)
138+ active := make([]Tool, 0, len(tools))
139+ for _, tool := range tools {
140+ if !tool.Deferred || activated[tool.Name] {
141+ active = append(active, tool)
142+ }
143+ }
144+ return active
145+}
146+
147+func activatedToolNames(messages []Message) map[string]bool {
148+ activated := make(map[string]bool)
149+ for _, message := range messages {
150+ for _, name := range message.AddedToolNames {
151+ activated[name] = true
152+ }
153+ }
154+ return activated
155+}
156+
133157 // Usage aggregates token counts and cost for a turn.
134158 type Usage struct {
135159 InputTokens int `json:"input_tokens"`
@@ -130,6 +130,30 @@ type Tool struct {
130 Deferred bool `json:"deferred,omitempty"`130 Deferred bool `json:"deferred,omitempty"`
131 }131 }
132 132
133+// activeToolDefinitions returns eager tools plus deferred tools activated by
134+// prior tool results. Providers with a native position-sensitive deferred-tool
135+// format can use the activation markers directly instead.
136+func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
137+ activated := activatedToolNames(messages)
138+ active := make([]Tool, 0, len(tools))
139+ for _, tool := range tools {
140+ if !tool.Deferred || activated[tool.Name] {
141+ active = append(active, tool)
142+ }
143+ }
144+ return active
145+}
146+
147+func activatedToolNames(messages []Message) map[string]bool {
148+ activated := make(map[string]bool)
149+ for _, message := range messages {
150+ for _, name := range message.AddedToolNames {
151+ activated[name] = true
152+ }
153+ }
154+ return activated
155+}
156+
133 // Usage aggregates token counts and cost for a turn.157 // Usage aggregates token counts and cost for a turn.
134 type Usage struct {158 type Usage struct {
135 InputTokens int `json:"input_tokens"`159 InputTokens int `json:"input_tokens"`

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