mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 22:13:34 +00:00
The bridge exposed a static BridgeToolNames subset that drifted from the tool registry: use_skill, datetime, knowledge_graph_search and skill_manage were never added, while the system prompt's skill-loading protocol requires agents to call use_skill. claude_cli agents following the protocol hit a nonexistent tool and could fabricate results. Implement the structural fix recommended in #1373 triage: - register the full bridge-capable surface (registry minus hard exclusions spawn/create_forum_topic) instead of the static list - gate BOTH tools/list (new WithToolFilter) and tools/call through one shared predicate bridgeToolAllowed: * callers WITH a verified agent policy get exactly the policy-filtered surface (same WouldAllow check the call path always enforced) * callers WITHOUT one (anonymous, or agent without tools_config) keep the legacy conservative BridgeToolNames set - no exposure widening - downgrade the per-call denial log Warn->Info; list filtering makes probes of denied tools rare and the call gate is the intended enforcement point Fixes #1373
275 lines
9.9 KiB
Go
275 lines
9.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"mime"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
|
mcpserver "github.com/mark3labs/mcp-go/server"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// BridgeToolNames is the LEGACY conservative tool set, kept as the fallback
|
|
// surface for callers that carry no verified agent tool policy in context
|
|
// (no/invalid X-Agent-ID headers, or an agent without a tools_config). For
|
|
// callers WITH an agent policy, the bridge surface is derived from that
|
|
// policy instead — see bridgeToolAllowed. This removes the drift where the
|
|
// system prompt requires tools (e.g. use_skill) that a static list forgot to
|
|
// expose (#1373), without widening exposure for unauthenticated callers.
|
|
// delegate is included: it self-gates via CanDelegate/agent_links and resolves
|
|
// its source agent from the X-Agent-ID header context, same as team_tasks.
|
|
var BridgeToolNames = map[string]bool{
|
|
// Filesystem
|
|
"read_file": true,
|
|
"write_file": true,
|
|
"list_files": true,
|
|
"edit": true,
|
|
"exec": true,
|
|
// Web
|
|
"web_search": true,
|
|
"web_fetch": true,
|
|
// Memory & knowledge
|
|
"memory_search": true,
|
|
"memory_get": true,
|
|
"skill_search": true,
|
|
// Media
|
|
"read_image": true,
|
|
"create_image": true,
|
|
"tts": true,
|
|
// Browser automation
|
|
"browser": true,
|
|
// Scheduler
|
|
"cron": true,
|
|
// Messaging (send text/files to channels)
|
|
"message": true,
|
|
// Sessions (read + send)
|
|
"sessions_list": true,
|
|
"session_status": true,
|
|
"sessions_history": true,
|
|
"sessions_send": true,
|
|
// Team tools (context from X-Agent-ID/X-Channel/X-Chat-ID headers)
|
|
"team_tasks": true,
|
|
// Inter-agent delegation (self-gates via CanDelegate / agent_links)
|
|
"delegate": true,
|
|
}
|
|
|
|
// bridgeExcludedTools lists tools that cannot operate over the bridge at all,
|
|
// regardless of any agent policy: spawn drives the in-process agent loop and
|
|
// create_forum_topic needs a live channel connection owned by the gateway.
|
|
var bridgeExcludedTools = map[string]bool{
|
|
"spawn": true,
|
|
"create_forum_topic": true,
|
|
}
|
|
|
|
// bridgeRegisteredToolNames returns every registry tool the bridge may ever
|
|
// expose: the full registry minus hard exclusions. Which subset a given
|
|
// caller can actually list/call is decided per-request by bridgeToolAllowed.
|
|
func bridgeRegisteredToolNames(reg *tools.Registry) []string {
|
|
var names []string
|
|
for _, name := range reg.List() {
|
|
if bridgeExcludedTools[name] {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// bridgeToolAllowed is the single predicate for both tools/list filtering and
|
|
// tools/call gating:
|
|
// - hard-excluded tools are never allowed;
|
|
// - callers WITHOUT a verified agent policy (or when no policy engine is
|
|
// wired) fall back to the legacy conservative BridgeToolNames set, so
|
|
// anonymous exposure is unchanged;
|
|
// - callers WITH an agent policy get exactly the policy-filtered surface
|
|
// (same WouldAllow predicate the call path always enforced).
|
|
func bridgeToolAllowed(reg *tools.Registry, policyEngine *tools.PolicyEngine, ctx context.Context, name string) bool {
|
|
if bridgeExcludedTools[name] {
|
|
return false
|
|
}
|
|
agentPolicy := tools.ToolAgentPolicyFromCtx(ctx)
|
|
if policyEngine == nil || agentPolicy == nil {
|
|
return BridgeToolNames[name]
|
|
}
|
|
return policyEngine.WouldAllow(reg, name, bridgeProviderName, agentPolicy, nil)
|
|
}
|
|
|
|
// newBridgeToolFilter returns a tools/list filter so each caller only sees
|
|
// the tools it can actually call. Without this, the CLI probes tools that the
|
|
// call path then denies, wasting agent turns and spamming denial logs.
|
|
func newBridgeToolFilter(reg *tools.Registry, policyEngine *tools.PolicyEngine) mcpserver.ToolFilterFunc {
|
|
return func(ctx context.Context, listed []mcpgo.Tool) []mcpgo.Tool {
|
|
filtered := make([]mcpgo.Tool, 0, len(listed))
|
|
for _, tool := range listed {
|
|
if bridgeToolAllowed(reg, policyEngine, ctx, tool.Name) {
|
|
filtered = append(filtered, tool)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
}
|
|
|
|
// bridgeProviderName identifies the caller for per-provider tool policy
|
|
// overrides (config.ToolPolicySpec.ByProvider) when enforcing bridge access.
|
|
// All MCP bridge traffic originates from the Claude CLI subprocess.
|
|
const bridgeProviderName = "claude-cli"
|
|
|
|
// NewBridgeServer creates a StreamableHTTPServer that exposes GoClaw tools as MCP tools.
|
|
// It reads tools from the registry, filters to BridgeToolNames, and serves them
|
|
// over streamable-http transport (stateless mode).
|
|
// msgBus is optional; when non-nil, tools that produce media (deliver:true) will
|
|
// publish file attachments directly to the outbound bus.
|
|
// policyEngine is optional; when non-nil, every tool call is additionally checked
|
|
// against the calling agent's policy-filtered allowlist (see
|
|
// tools.WithToolAgentPolicy / ToolAgentPolicyFromCtx) before execution, so a
|
|
// tool present in BridgeToolNames is still rejected if the agent's own tool
|
|
// policy denies it. When nil, only the static BridgeToolNames set applies
|
|
// (legacy behavior).
|
|
func NewBridgeServer(reg *tools.Registry, version string, msgBus *bus.MessageBus, policyEngine *tools.PolicyEngine) *mcpserver.StreamableHTTPServer {
|
|
srv := mcpserver.NewMCPServer("goclaw-bridge", version,
|
|
mcpserver.WithToolCapabilities(false),
|
|
// Per-caller list filtering: each agent only sees its callable surface.
|
|
mcpserver.WithToolFilter(newBridgeToolFilter(reg, policyEngine)),
|
|
)
|
|
|
|
// Register the full bridge-capable surface (registry minus hard
|
|
// exclusions). Per-caller visibility and callability are both decided by
|
|
// bridgeToolAllowed at request time.
|
|
var registered int
|
|
for _, name := range bridgeRegisteredToolNames(reg) {
|
|
t, ok := reg.Get(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
mcpTool := convertToMCPTool(t)
|
|
handler := makeToolHandler(reg, name, msgBus, policyEngine)
|
|
srv.AddTool(mcpTool, handler)
|
|
registered++
|
|
}
|
|
|
|
slog.Info("mcp.bridge: tools registered", "count", registered)
|
|
|
|
return mcpserver.NewStreamableHTTPServer(srv,
|
|
mcpserver.WithStateLess(true),
|
|
)
|
|
}
|
|
|
|
// convertToMCPTool converts a GoClaw tools.Tool into an mcp-go Tool.
|
|
func convertToMCPTool(t tools.Tool) mcpgo.Tool {
|
|
schema, err := json.Marshal(t.Parameters())
|
|
if err != nil {
|
|
// Fallback: empty object schema
|
|
schema = []byte(`{"type":"object"}`)
|
|
}
|
|
return mcpgo.NewToolWithRawSchema(t.Name(), t.Description(), schema)
|
|
}
|
|
|
|
// makeToolHandler creates a ToolHandlerFunc that delegates to the GoClaw tool registry.
|
|
// When msgBus is non-nil and a tool result contains Media paths, the handler publishes
|
|
// them as outbound media attachments so files reach the user (e.g. Telegram document).
|
|
func makeToolHandler(reg *tools.Registry, toolName string, msgBus *bus.MessageBus, policyEngine *tools.PolicyEngine) mcpserver.ToolHandlerFunc {
|
|
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
|
|
// Gate execution with the same predicate that filters tools/list, so
|
|
// visibility and callability can never drift apart. Registration only
|
|
// bounds what the bridge process COULD ever expose; it does not know
|
|
// which agent is calling.
|
|
// Info (not Warn): the per-call gate is the intended enforcement
|
|
// point and the CLI legitimately probes; list filtering already keeps
|
|
// this rare.
|
|
if !bridgeToolAllowed(reg, policyEngine, ctx, toolName) {
|
|
slog.Info("mcp_bridge_denied",
|
|
"tool", toolName, "agent_key", tools.ToolAgentKeyFromCtx(ctx))
|
|
return mcpgo.NewToolResultError("tool not allowed by policy: " + toolName), nil
|
|
}
|
|
|
|
args := req.GetArguments()
|
|
|
|
// Pass routing context (channel, chatID, peerKind, sessionKey) so native
|
|
// tools can access local_key, session_key etc. for forum topic routing.
|
|
result := reg.ExecuteWithContext(ctx, toolName, args,
|
|
tools.ToolChannelFromCtx(ctx),
|
|
tools.ToolChatIDFromCtx(ctx),
|
|
tools.ToolPeerKindFromCtx(ctx),
|
|
tools.ToolSessionKeyFromCtx(ctx),
|
|
nil,
|
|
)
|
|
|
|
if result.IsError {
|
|
return mcpgo.NewToolResultError(result.ForLLM), nil
|
|
}
|
|
|
|
// Forward media files to the outbound bus so they reach the user as attachments.
|
|
// This is necessary because Claude CLI processes tool results internally —
|
|
// GoClaw's agent loop never sees result.Media from bridge tool calls.
|
|
forwardMediaToOutbound(ctx, msgBus, toolName, result)
|
|
|
|
return mcpgo.NewToolResultText(result.ForLLM), nil
|
|
}
|
|
}
|
|
|
|
// forwardMediaToOutbound publishes media files from a tool result to the outbound bus.
|
|
func forwardMediaToOutbound(ctx context.Context, msgBus *bus.MessageBus, toolName string, result *tools.Result) {
|
|
if msgBus == nil || len(result.Media) == 0 {
|
|
return
|
|
}
|
|
channel := tools.ToolChannelFromCtx(ctx)
|
|
chatID := tools.ToolChatIDFromCtx(ctx)
|
|
if channel == "" || chatID == "" {
|
|
slog.Debug("mcp.bridge: skipping media forward, missing channel context",
|
|
"tool", toolName, "channel", channel, "chat_id", chatID)
|
|
return
|
|
}
|
|
|
|
var attachments []bus.MediaAttachment
|
|
for _, mf := range result.Media {
|
|
ct := mf.MimeType
|
|
if ct == "" {
|
|
ct = mimeFromExt(filepath.Ext(mf.Path))
|
|
}
|
|
attachments = append(attachments, bus.MediaAttachment{
|
|
URL: mf.Path,
|
|
ContentType: ct,
|
|
})
|
|
}
|
|
|
|
peerKind := tools.ToolPeerKindFromCtx(ctx)
|
|
var meta map[string]string
|
|
if peerKind == "group" {
|
|
meta = map[string]string{"group_id": chatID}
|
|
}
|
|
msgBus.PublishOutbound(bus.OutboundMessage{
|
|
Channel: channel,
|
|
ChatID: chatID,
|
|
Media: attachments,
|
|
Metadata: meta,
|
|
})
|
|
slog.Debug("mcp.bridge: forwarded media to outbound bus",
|
|
"tool", toolName, "channel", channel, "files", len(attachments))
|
|
}
|
|
|
|
// mimeFromExt returns a MIME type for a file extension.
|
|
// Uses Go stdlib first, falls back to a small map for types not reliably
|
|
// handled by mime.TypeByExtension on all platforms (e.g. .opus, .webp).
|
|
func mimeFromExt(ext string) string {
|
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
|
return ct
|
|
}
|
|
switch strings.ToLower(ext) {
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".opus":
|
|
return "audio/ogg"
|
|
case ".md":
|
|
return "text/markdown"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|