Files
goclaw/internal/mcp/server_tool_infos_test.go
a3fe5f7858 fix(mcp): resolve tool descriptions/schemas consistently across all MCP tool-listing paths (#1369)
* fix(mcp): return bare tool names with descriptions for already-connected MCP servers, fixing empty descriptions/schemas in agent prompts

* fix(mcp): resolve live tool descriptions/schemas in ListToolsForAgent for legacy prefixed tool_allow entries

The previous fix (d612da8e) only corrected the admin "browse/select tools"
endpoint. The actual runtime path building the LLM system prompt's tool list,
Manager.ListToolsForAgent, still looked up tool_cache/registry entries keyed
by the raw stored tool_allow name. Agents with grants captured before that
fix have tool_allow persisted as registered (prefixed) names instead of bare
MCP tool names, so every lookup missed and every tool in the prompt preview
came back with an empty description and schema.

Add bareMCPToolName to normalize both legacy-prefixed and current bare
tool_allow/tool_deny entries before matching, and resolveMCPToolInfo to
prefer the live registry's current description/schema (falling back to the
settings tool_cache) instead of trusting whatever was persisted verbatim.
Both the explicit-allow and cache-enumeration branches now share this single
resolution path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 04:39:07 +07:00

126 lines
4.4 KiB
Go

package mcp
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/google/uuid"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// TestSelfConnectedServer_CacheRetainsDescriptionAndSchema is a regression
// test for a reported bug where connecting to an already-connected MCP
// server as a client showed tools with an empty description and empty
// parameters in the system-prompt preview (`- function: {description: '',
// parameters: {type: object}}`), while newly-discovered tools came through
// with full schema.
//
// It builds a minimal generic MCP server directly with the underlying
// mark3labs/mcp-go library (no dependency on any goclaw-specific server such
// as the CRUD server), serves it over streamable-http via httptest
// (mirroring a goclaw gateway connecting to an MCP endpoint), connects to it
// with the exact client path used by the Manager (connectAndDiscover), and
// proves the resulting tool cache (buildCachedToolInfo) — and the full
// downstream pipeline through ListToolsForAgent with a bare-name tool_allow
// grant — retains the real description and JSON Schema.
func TestSelfConnectedServer_CacheRetainsDescriptionAndSchema(t *testing.T) {
srv := mcpserver.NewMCPServer("test-server", "1.0.0", mcpserver.WithToolCapabilities(false))
tool := mcpgo.NewTool("test_tool",
mcpgo.WithDescription("A simple test tool used to verify description/schema propagation."),
mcpgo.WithString("param",
mcpgo.Description("An example string parameter."),
mcpgo.Required(),
),
)
srv.AddTool(tool, func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
return mcpgo.NewToolResultText("ok"), nil
})
httpSrv := mcpserver.NewStreamableHTTPServer(srv)
ts := httptest.NewServer(httpSrv)
defer ts.Close()
// "goclaw" is the same ClientInfo.Name used by connectAndDiscover in
// production (manager_connect.go).
ss, mcpTools, err := connectAndDiscover(context.Background(), "goclaw", "streamable-http", "", nil, nil, ts.URL, nil, 10)
if err != nil {
t.Fatalf("connectAndDiscover: %v", err)
}
defer func() { _ = ss.client.Close() }()
cache := buildCachedToolInfo(mcpTools)
entry, ok := cache["test_tool"]
if !ok {
t.Fatalf("expected test_tool in cache, got %+v", cache)
}
if entry.Description == "" {
t.Fatal("expected non-empty description for test_tool, got empty (regression)")
}
if len(entry.Parameters) == 0 {
t.Fatal("expected non-empty parameters schema for test_tool, got empty (regression)")
}
var schema map[string]any
if err := json.Unmarshal(entry.Parameters, &schema); err != nil {
t.Fatalf("unmarshal cached parameters: %v", err)
}
props, ok := schema["properties"].(map[string]any)
if !ok || len(props) == 0 {
t.Fatalf("expected non-empty properties in cached schema, got %+v", schema)
}
if _, ok := props["param"]; !ok {
t.Fatalf("expected 'param' property in cached schema, got %+v", props)
}
// End-to-end: feed the cache into ListToolsForAgent exactly as it is
// persisted in production (settings.tool_cache), with an explicit
// tool_allow grant using the BARE tool name — the shape ListToolsForAgent
// expects (internal/mcp/manager.go) and the shape ServerToolInfos returns
// (internal/mcp/manager_tools.go).
settings, err := json.Marshal(map[string]any{"tool_cache": cache})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
serverID := uuid.New()
mockStore := &mockMCPStore{
accessible: []store.MCPAccessInfo{
{
Server: store.MCPServerData{
BaseModel: store.BaseModel{ID: serverID},
Name: "goclaw",
Enabled: true,
Settings: settings,
},
ToolAllow: []string{"test_tool"},
},
},
}
mgr := NewManager(tools.NewRegistry(), WithStore(mockStore))
previews, err := mgr.ListToolsForAgent(t.Context(), uuid.New(), "user-1")
if err != nil {
t.Fatalf("ListToolsForAgent: %v", err)
}
var found *MCPToolPreviewInfo
for i := range previews {
if previews[i].RegisteredName == "mcp_goclaw__test_tool" {
found = &previews[i]
}
}
if found == nil {
t.Fatalf("expected mcp_goclaw__test_tool in preview, got %+v", previews)
}
if found.Description == "" {
t.Fatal("preview: expected non-empty description (regression)")
}
if len(found.Parameters) == 0 {
t.Fatal("preview: expected non-empty parameters (regression)")
}
}