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 } // A delegated artifact is not outbound media until DelegateTool validates // and atomically publishes its manifest back into the caller workspace. tools.ApplyDelegationArtifactResultPolicy(ctx, result) // 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 || tools.IsDelegationArtifactRun(ctx) || 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" } }