Files
goclaw/internal/agent/loop_user_mcp_tools_surface_test.go
T
SYNITYandDangTinh311 8eef0509ce fix(agent): surface per-user MCP tools to the LLM tool list [B24:2794] (#1235)
require_user_credentials MCP servers loaded and connected, but their tool
defs were never sent to the model, so the agent could never call the
per-user mcp_<prefix>__* tools (it only ever saw the shared MCP server).
The per-user tool objects are intentionally kept out of the shared registry
to prevent cross-user credential leaks, so FilterTools could not emit them.

Surface them through a request-scoped overlay registry passed to the policy
engine: per-user tools are now evaluated AND emitted under the same
allow/deny rules as registry tools, then resolved per-actor at execution
time via executeToolForActor. The overlay is discarded after filtering, so
the shared registry and other users stay unaffected.

- add tools.NewUserToolOverlay (request-scoped, discarded post-filter)
- buildFilteredTools: wrap registry in overlay when per-user tools present;
  no-policy path appends their defs directly
- fix stale getUserMCPTools docstring (claimed shared-registry registration)
- tests: overlay policy allow/deny + per-user surfacing / strip / dedup

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
2026-06-21 08:06:07 +07:00

89 lines
3.3 KiB
Go

package agent
// Regression tests: per-user MCP tools (require_user_credentials servers) must be
// surfaced to the LLM tool list. Their objects live ONLY in l.mcpUserTools (cross-user
// isolation) and are NEVER in the shared registry, so the ONLY path that exposes their
// defs to the model is the userTools argument of buildFilteredTools. A prior regression
// (anti-leak hardening) dropped that merge → the model could never call mcp_<prefix>__*.
// See plans/reports/bugreport-260617-1708-per-user-mcp-tools-not-surfaced-to-llm.md.
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// fakeUserMCPTool is a minimal tools.Tool standing in for a per-user MCP BridgeTool.
type fakeUserMCPTool struct{ name string }
func (f *fakeUserMCPTool) Name() string { return f.name }
func (f *fakeUserMCPTool) Description() string { return "per-user mcp tool stub" }
func (f *fakeUserMCPTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
func (f *fakeUserMCPTool) Execute(context.Context, map[string]any) *tools.Result {
return &tools.Result{ForLLM: "ok"}
}
// hasToolNamed reports whether defs contains a function tool with the given name.
func hasToolNamed(defs []providers.ToolDefinition, name string) bool {
for _, d := range defs {
if d.Function != nil && d.Function.Name == name {
return true
}
}
return false
}
// ─── Per-user MCP tools appear in the LLM tool list ───────────────────────
func TestUserMCPTools_SurfacedToLLM(t *testing.T) {
l := buildImageGenLoop(false, &stubProvider{}) // toolPolicy nil → defs come only from userTools
userTools := []tools.Tool{
&fakeUserMCPTool{name: "mcp_bx24__search"},
&fakeUserMCPTool{name: "mcp_bx24__execute"},
}
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil, userTools)
if !hasToolNamed(defs, "mcp_bx24__search") || !hasToolNamed(defs, "mcp_bx24__execute") {
t.Errorf("per-user MCP tools must surface to the LLM tool list; got %d defs: %v", len(defs), defs)
}
}
// ─── Final iteration strips per-user MCP tools too (force text response) ───
func TestUserMCPTools_FinalIterationStripped(t *testing.T) {
l := buildImageGenLoop(false, &stubProvider{})
userTools := []tools.Tool{&fakeUserMCPTool{name: "mcp_bx24__execute"}}
// iteration == maxIter → final stripping path: all tools removed, including per-user.
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 5, 5, nil, userTools)
if len(defs) != 0 {
t.Errorf("final iteration must strip per-user MCP tools; got %d: %v", len(defs), defs)
}
}
// ─── Duplicate per-user tool names emit only one def ──────────────────────
func TestUserMCPTools_DedupByName(t *testing.T) {
l := buildImageGenLoop(false, &stubProvider{})
userTools := []tools.Tool{
&fakeUserMCPTool{name: "mcp_bx24__execute"},
&fakeUserMCPTool{name: "mcp_bx24__execute"}, // duplicate
}
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil, userTools)
count := 0
for _, d := range defs {
if d.Function != nil && d.Function.Name == "mcp_bx24__execute" {
count++
}
}
if count != 1 {
t.Errorf("duplicate per-user tool name must emit exactly one def; got %d", count)
}
}