Files
goclaw/internal/mcp/bridge_server.go
T
MToan 09d8660f3b fix(mcp): derive claude_cli bridge tool surface from agent policy (#1377)
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
2026-07-07 11:23:11 +07:00

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"
}
}