mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 12:13:15 +00:00
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>
116 lines
4.5 KiB
Go
116 lines
4.5 KiB
Go
package tools
|
|
|
|
// Tests for userToolOverlay — the request-scoped registry overlay that surfaces
|
|
// per-user MCP tools through the PolicyEngine without registering them in the shared
|
|
// registry. The decisive case is TestUserToolOverlay_PolicyDenyByNameSuppresses:
|
|
// it proves per-user MCP tools now honor an agent/global deny (Finding 1 fix), which
|
|
// the previous "append after FilterTools" approach bypassed.
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// overlayFakeTool is a minimal Tool standing in for a per-user MCP BridgeTool.
|
|
type overlayFakeTool struct{ name string }
|
|
|
|
func (f *overlayFakeTool) Name() string { return f.name }
|
|
func (f *overlayFakeTool) Description() string { return "fake " + f.name }
|
|
func (f *overlayFakeTool) Parameters() map[string]any { return map[string]any{"type": "object"} }
|
|
func (f *overlayFakeTool) Execute(context.Context, map[string]any) *Result {
|
|
return &Result{ForLLM: "ok"}
|
|
}
|
|
|
|
// emptyBaseExecutor is a ToolExecutor with no tools — stands in for a shared registry
|
|
// that does NOT contain the per-user MCP tools (the real-world condition).
|
|
type emptyBaseExecutor struct{}
|
|
|
|
func (emptyBaseExecutor) ExecuteWithContext(context.Context, string, map[string]any, string, string, string, string, AsyncCallback) *Result {
|
|
return &Result{}
|
|
}
|
|
func (emptyBaseExecutor) TryActivateDeferred(string) bool { return false }
|
|
func (emptyBaseExecutor) ProviderDefs() []providers.ToolDefinition { return nil }
|
|
func (emptyBaseExecutor) Get(string) (Tool, bool) { return nil, false }
|
|
func (emptyBaseExecutor) List() []string { return nil }
|
|
func (emptyBaseExecutor) Aliases() map[string]string { return nil }
|
|
|
|
func defNameSet(defs []providers.ToolDefinition) map[string]bool {
|
|
m := make(map[string]bool, len(defs))
|
|
for _, d := range defs {
|
|
if d.Function != nil {
|
|
m[d.Function.Name] = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ─── Overlay exposes per-user tools via List/Get ──────────────────────────
|
|
|
|
func TestUserToolOverlay_AddsPerUserTools(t *testing.T) {
|
|
ov := NewUserToolOverlay(emptyBaseExecutor{}, []Tool{
|
|
&overlayFakeTool{name: "mcp_bx24__execute"},
|
|
&overlayFakeTool{name: "mcp_bx24__search"},
|
|
})
|
|
|
|
list := ov.List()
|
|
if len(list) != 2 {
|
|
t.Fatalf("expected 2 names in overlay List, got %d: %v", len(list), list)
|
|
}
|
|
if _, ok := ov.Get("mcp_bx24__execute"); !ok {
|
|
t.Error("overlay must resolve per-user tool via Get")
|
|
}
|
|
if _, ok := ov.Get("does_not_exist"); ok {
|
|
t.Error("overlay must not resolve unknown tools")
|
|
}
|
|
}
|
|
|
|
// ─── Empty / all-nil userTools returns base unchanged ─────────────────────
|
|
|
|
func TestUserToolOverlay_EmptyReturnsBase(t *testing.T) {
|
|
base := emptyBaseExecutor{}
|
|
if ov := NewUserToolOverlay(base, nil); ov.List() != nil {
|
|
t.Error("nil userTools should return base unchanged (nil List)")
|
|
}
|
|
if ov := NewUserToolOverlay(base, []Tool{nil, nil}); ov.List() != nil {
|
|
t.Error("all-nil userTools should return base unchanged (nil List)")
|
|
}
|
|
}
|
|
|
|
// ─── Policy with default (full) profile emits per-user tools ──────────────
|
|
|
|
func TestUserToolOverlay_PolicyEmitsByDefault(t *testing.T) {
|
|
pe := NewPolicyEngine(&config.ToolsConfig{}) // empty profile = full = all allowed
|
|
ov := NewUserToolOverlay(emptyBaseExecutor{}, []Tool{
|
|
&overlayFakeTool{name: "mcp_bx24__execute"},
|
|
&overlayFakeTool{name: "mcp_bx24__search"},
|
|
})
|
|
|
|
defs := pe.FilterTools(ov, "agent", "openai", nil, nil, false, false)
|
|
names := defNameSet(defs)
|
|
if !names["mcp_bx24__execute"] || !names["mcp_bx24__search"] {
|
|
t.Errorf("full profile must emit per-user tools through the overlay; got %v", names)
|
|
}
|
|
}
|
|
|
|
// ─── Finding 1 fix: an explicit deny on a per-user tool name suppresses it ─
|
|
|
|
func TestUserToolOverlay_PolicyDenyByNameSuppresses(t *testing.T) {
|
|
pe := NewPolicyEngine(&config.ToolsConfig{Deny: []string{"mcp_bx24__execute"}})
|
|
ov := NewUserToolOverlay(emptyBaseExecutor{}, []Tool{
|
|
&overlayFakeTool{name: "mcp_bx24__execute"},
|
|
&overlayFakeTool{name: "mcp_bx24__search"},
|
|
})
|
|
|
|
defs := pe.FilterTools(ov, "agent", "openai", nil, nil, false, false)
|
|
names := defNameSet(defs)
|
|
if names["mcp_bx24__execute"] {
|
|
t.Error("denied per-user tool must NOT be emitted (Finding 1: policy must apply)")
|
|
}
|
|
if !names["mcp_bx24__search"] {
|
|
t.Error("non-denied per-user tool must still be emitted")
|
|
}
|
|
}
|