fix(mcp-bridge): per-session security context + media forwarding (#91)

* fix(mcp-bridge): add per-session agent context and HMAC verification

- Add per-session MCP config with X-Agent-ID/X-User-ID headers instead
  of shared global config file
- Sign bridge context headers with HMAC-SHA256 to prevent forgery
- Add bridgeContextMiddleware to verify signatures on MCP bridge requests
- Store MCP configs in ~/.goclaw/mcp-configs/ outside agent workDir
- Use atomic writes (tmp + rename) for MCP config files
- Fix provider rename leaving ghost registry entries
- Remove provider_type from mutable fields on update
- Tighten temp dir permissions from 0755 to 0700

* feat(mcp-bridge): propagate channel routing context through MCP bridge

- Pass channel, chat_id, and peer_kind from agent loop to CLI provider options
- Inject X-Channel, X-Chat-ID, X-Peer-Kind headers in bridge context middleware
- Add BridgeContext struct to bundle per-call context for MCP config generation
- Include channel routing headers in per-session MCP config files
- Expose "message" tool via MCP bridge for cross-channel messaging
- Add extract helpers for new option keys in claude_cli_session.go

* feat(mcp-bridge): forward media attachments to outbound message bus

- Wire MessageBus into gateway server and MCP bridge handler
- Publish tool result media files to outbound bus for channel delivery
- Extract channel/chatID/peerKind from tool context for proper routing
- Add mimeFromExt helper for content-type detection on attachments

* feat(mcp-bridge): inject per-agent DB-backed MCP servers into Claude CLI config

- Add MCPServerLookup type to resolve agent-specific MCP servers from DB
- Wire MCPServerStore through provider registration and HTTP handler
- Extract mcpServerEntryToConfig helper to deduplicate transport config logic
- Add JSON-to-Go helpers (jsonToStringSlice, jsonToStringMap) for DB fields
- Merge per-agent MCP servers at config write time without overriding static entries

* fix(mcp-bridge): use Media struct fields and prefer explicit MimeType

- Map Media.Path to attachment URL instead of treating Media as string
- Use Media.MimeType when available, fall back to extension-based detection

* refactor(providers): deduplicate option extractors and extract bridge media forwarding

- Replace per-field extractors (extractSessionKey, extractAgentID, etc.) with generic extractStringOpt/extractBoolOpt
- Add bridgeContextFromOpts helper to build BridgeContext in one call
- Extract forwardMediaToOutbound from inline block in makeToolHandler
- Change NewBridgeServer msgBus param from variadic to explicit pointer

* fix(providers): validate provider_type on update instead of silently dropping it

- Add explicit validation against ValidProviderTypes with 400 response
- Remove silent delete(updates, "provider_type") that hid invalid values
- Caller now receives clear error when submitting unsupported provider_type

* fix(providers): add header injection validation to MCP bridge headers

- Extend CRLF/null-byte checks to agentID, channel, chatID, and peerKind
- Previously only userID had header injection prevention
- Prevents HTTP header injection via crafted values in MCP config

* fix(mcp-bridge): sign all context fields in HMAC and remove legacy code

- Sign all 5 bridge context fields (agentID|userID|channel|chatID|peerKind)
  in HMAC instead of only agentID|userID to prevent channel routing forgery
- Propagate context.Context into MCPServerLookup to respect request
  cancellation instead of using context.Background()
- Remove legacy BuildCLIMCPConfig, WithClaudeCLIMCPConfig, mcpConfigPath,
  and mcpCleanup (dead code since system is PG-only)
- Use mime.TypeByExtension before custom fallback in mimeFromExt
- Add debug log when media forwarding is skipped due to missing context
- Add thread-safety comment to SetMCPServerLookup

---------

Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-09 15:23:56 +07:00
1 parent b284f963f5
commit 11bed0cc01
11 files changed
+567 -146

No files matched your search

+2 -1
View File
@@ -364,7 +364,7 @@ func runGateway() {
// Register providers from DB (overrides config providers).
if pgStores.Providers != nil {
dbGatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token)
registerProvidersFromDB(providerRegistry, pgStores.Providers, pgStores.ConfigSecrets, dbGatewayAddr, cfg.Gateway.Token, pgStores.MCP)
}
// Wire embedding provider to PGMemoryStore so IndexDocument generates vectors.
@@ -552,6 +552,7 @@ func runGateway() {
server.SetDB(pgStores.DB)
server.SetPolicyEngine(permPE)
server.SetPairingService(pgStores.Pairing)
server.SetMessageBus(msgBus)
server.SetOAuthHandler(httpapi.NewOAuthHandler(cfg.Gateway.Token, pgStores.Providers, pgStores.ConfigSecrets, providerRegistry))
// contextFileInterceptor is created inside wireExtras.
+3
View File
@@ -57,6 +57,9 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
if stores != nil && stores.Providers != nil {
providersH = httpapi.NewProvidersHandler(stores.Providers, stores.ConfigSecrets, token, providerReg, gatewayAddr)
if stores.MCP != nil {
providersH.SetMCPServerLookup(buildMCPServerLookup(stores.MCP))
}
}
if stores != nil && stores.Teams != nil {
+72 -14
View File
@@ -2,12 +2,15 @@ package cmd
import (
"context"
"encoding/json"
"log/slog"
"net"
"os/exec"
"path/filepath"
"strconv"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/oauth"
"github.com/nextlevelbuilder/goclaw/internal/providers"
@@ -108,14 +111,10 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) {
if cfg.Providers.ClaudeCLI.PermMode != "" {
opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode))
}
// Build MCP config: external MCP servers + GoClaw bridge (built-in tools via streamable-http)
// Build per-session MCP config: external MCP servers + GoClaw bridge
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
mcpPath, mcpCleanup, err := providers.BuildCLIMCPConfig(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
if err != nil {
slog.Warn("failed to build MCP config for claude-cli", "error", err)
} else if mcpPath != "" {
opts = append(opts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
}
mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData))
// Enable GoClaw security hooks (shell deny patterns, path restrictions)
opts = append(opts, providers.WithClaudeCLISecurityHooks(
cfg.Providers.ClaudeCLI.BaseWorkDir, true))
@@ -124,10 +123,72 @@ func registerProviders(registry *providers.Registry, cfg *config.Config) {
}
}
// buildMCPServerLookup creates an MCPServerLookup from an MCPServerStore.
// Returns nil if mcpStore is nil.
func buildMCPServerLookup(mcpStore store.MCPServerStore) providers.MCPServerLookup {
if mcpStore == nil {
return nil
}
return func(ctx context.Context, agentID string) []providers.MCPServerEntry {
aid, err := uuid.Parse(agentID)
if err != nil {
return nil
}
accessible, err := mcpStore.ListAccessible(ctx, aid, "")
if err != nil {
slog.Warn("claude-cli: failed to list agent MCP servers", "agent_id", agentID, "error", err)
return nil
}
var entries []providers.MCPServerEntry
for _, info := range accessible {
srv := info.Server
if !srv.Enabled {
continue
}
entry := providers.MCPServerEntry{
Name: srv.Name,
Transport: srv.Transport,
Command: srv.Command,
URL: srv.URL,
Args: jsonToStringSlice(srv.Args),
Headers: jsonToStringMap(srv.Headers),
Env: jsonToStringMap(srv.Env),
}
entries = append(entries, entry)
}
return entries
}
}
// jsonToStringSlice converts a json.RawMessage to []string.
func jsonToStringSlice(data json.RawMessage) []string {
if len(data) == 0 {
return nil
}
var result []string
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
}
// jsonToStringMap converts a json.RawMessage to map[string]string.
func jsonToStringMap(data json.RawMessage) map[string]string {
if len(data) == 0 {
return nil
}
var result map[string]string
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
}
// registerProvidersFromDB loads providers from Postgres and registers them.
// DB providers are registered after config providers, so they take precedence (overwrite).
// gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers.
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string) {
// mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config.
func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore) {
ctx := context.Background()
dbProviders, err := provStore.ListProviders(ctx)
if err != nil {
@@ -156,12 +217,9 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
var cliOpts []providers.ClaudeCLIOption
cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
if gatewayAddr != "" {
mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, gatewayAddr, gatewayToken)
if mcpErr != nil {
slog.Warn("failed to build MCP config for db claude-cli", "error", mcpErr)
} else if mcpPath != "" {
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
}
mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken)
mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore)
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
slog.Info("registered provider from DB", "name", p.Name)
+5
View File
@@ -395,6 +395,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
providers.OptMaxTokens: 8192,
providers.OptTemperature: 0.7,
providers.OptSessionKey: req.SessionKey,
providers.OptAgentID: l.agentUUID.String(),
providers.OptUserID: req.UserID,
providers.OptChannel: req.Channel,
providers.OptChatID: req.ChatID,
providers.OptPeerKind: req.PeerKind,
},
}
if l.thinkingLevel != "" && l.thinkingLevel != "off" {
+61 -3
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/nextlevelbuilder/goclaw/internal/agent"
@@ -19,6 +20,7 @@ import (
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -52,6 +54,7 @@ type Server struct {
mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint
mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint
agentStore store.AgentStore // for context injection in tools_invoke
msgBus *bus.MessageBus // for MCP bridge media delivery
upgrader websocket.Upgrader
rateLimiter *RateLimiter
@@ -239,20 +242,72 @@ func (s *Server) BuildMux() *http.ServeMux {
// MCP bridge: expose GoClaw tools to Claude CLI via streamable-http.
// Only listens on localhost (CLI runs on the same machine).
// Protected by gateway token when configured.
// Agent context (X-Agent-ID, X-User-ID) is injected from request headers.
if s.tools != nil {
bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0")
bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus)
var handler http.Handler = bridgeContextMiddleware(s.cfg.Gateway.Token, bridgeHandler)
if s.cfg.Gateway.Token != "" {
mux.Handle("/mcp/bridge", tokenAuthMiddleware(s.cfg.Gateway.Token, bridgeHandler))
handler = tokenAuthMiddleware(s.cfg.Gateway.Token, handler)
} else {
slog.Warn("security.mcp_bridge: no gateway token configured, MCP bridge tools are unauthenticated")
mux.Handle("/mcp/bridge", bridgeHandler)
}
mux.Handle("/mcp/bridge", handler)
}
s.mux = mux
return mux
}
// bridgeContextMiddleware extracts X-Agent-ID and X-User-ID headers from the
// MCP bridge request and injects them into the context so bridge tools can
// access agent/user scope. When a gateway token is configured, the context
// headers must be accompanied by a valid X-Bridge-Sig HMAC to prevent forgery.
func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
agentIDStr := r.Header.Get("X-Agent-ID")
userID := r.Header.Get("X-User-ID")
channel := r.Header.Get("X-Channel")
chatID := r.Header.Get("X-Chat-ID")
peerKind := r.Header.Get("X-Peer-Kind")
if agentIDStr != "" || userID != "" {
// Verify HMAC signature over all context fields when gateway token is configured
if gatewayToken != "" {
sig := r.Header.Get("X-Bridge-Sig")
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, sig) {
slog.Warn("security.mcp_bridge: invalid bridge context signature",
"agent_id", agentIDStr, "user_id", userID)
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
return
}
}
if agentIDStr != "" {
if id, err := uuid.Parse(agentIDStr); err == nil {
ctx = store.WithAgentID(ctx, id)
}
}
if userID != "" {
ctx = store.WithUserID(ctx, userID)
}
}
// Inject channel routing context for tools like message, cron, etc.
if channel != "" {
ctx = tools.WithToolChannel(ctx, channel)
}
if chatID != "" {
ctx = tools.WithToolChatID(ctx, chatID)
}
if peerKind != "" {
ctx = tools.WithToolPeerKind(ctx, peerKind)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// tokenAuthMiddleware wraps an http.Handler with Bearer token authentication.
func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -397,6 +452,9 @@ func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler =
// SetAgentStore sets the agent store for context injection in tools_invoke.
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
// SetMessageBus sets the message bus for MCP bridge media delivery.
func (s *Server) SetMessageBus(mb *bus.MessageBus) { s.msgBus = mb }
// SetVersion sets the server version for health responses.
func (s *Server) SetVersion(v string) { s.version = v }
+32 -13
View File
@@ -15,12 +15,13 @@ import (
// ProvidersHandler handles LLM provider CRUD endpoints.
type ProvidersHandler struct {
store store.ProviderStore
secretStore store.ConfigSecretsStore
token string
providerReg *providers.Registry
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
store store.ProviderStore
secretStore store.ConfigSecretsStore
token string
providerReg *providers.Registry
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
}
// NewProvidersHandler creates a handler for provider management endpoints.
@@ -28,6 +29,12 @@ func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsS
return &ProvidersHandler{store: s, secretStore: secretStore, token: token, providerReg: providerReg, gatewayAddr: gatewayAddr}
}
// SetMCPServerLookup sets the per-agent MCP server lookup for Claude CLI providers.
// Must be called before serving requests (not thread-safe).
func (h *ProvidersHandler) SetMCPServerLookup(lookup providers.MCPServerLookup) {
h.mcpLookup = lookup
}
// RegisterRoutes registers all provider management routes on the given mux.
func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
// Provider CRUD
@@ -81,12 +88,9 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) {
var cliOpts []providers.ClaudeCLIOption
cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true))
if h.gatewayAddr != "" {
mcpPath, mcpCleanup, mcpErr := providers.BuildCLIMCPConfig(nil, h.gatewayAddr, h.token)
if mcpErr != nil {
slog.Warn("failed to build MCP config for in-memory claude-cli", "error", mcpErr)
} else if mcpPath != "" {
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfig(mcpPath, mcpCleanup))
}
mcpData := providers.BuildCLIMCPConfigData(nil, h.gatewayAddr, h.token)
mcpData.AgentMCPLookup = h.mcpLookup
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...))
return
@@ -223,7 +227,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
}
}
// Validate provider_type if being updated
// Validate provider_type if being updated.
// IMPORTANT: Do NOT replace this with delete(updates, "provider_type").
// We must return 400 so the caller knows the value is invalid,
// silently deleting it would hide the error from the end user.
if pt, ok := updates["provider_type"]; ok {
if s, _ := pt.(string); !store.ValidProviderTypes[s] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"})
@@ -242,6 +249,14 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
delete(updates, "id")
delete(updates, "created_at")
// Track old name before update for registry cleanup
var oldName string
if h.providerReg != nil {
if old, err := h.store.GetProvider(r.Context(), id); err == nil {
oldName = old.Name
}
}
if err := h.store.UpdateProvider(r.Context(), id, updates); err != nil {
slog.Error("providers.update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -251,6 +266,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R
// Sync in-memory registry with updated provider
if h.providerReg != nil {
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
// Unregister old name if renamed to prevent ghost entries
if oldName != "" && oldName != updated.Name {
h.providerReg.Unregister(oldName)
}
if !updated.Enabled {
h.providerReg.Unregister(updated.Name)
} else {
+78 -4
View File
@@ -4,15 +4,19 @@ 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 subset of GoClaw tools exposed via the MCP bridge.
// Excluded: spawn (agent loop), message/create_forum_topic (channels),
// Excluded: spawn (agent loop), create_forum_topic (channels),
// handoff/delegate_search/evaluate_loop/team_* (managed mode stores).
var BridgeToolNames = map[string]bool{
// Filesystem
@@ -36,6 +40,8 @@ var BridgeToolNames = map[string]bool{
"browser": true,
// Scheduler
"cron": true,
// Messaging (send text/files to channels)
"message": true,
// Sessions (read + send)
"sessions_list": true,
"session_status": true,
@@ -46,7 +52,9 @@ var BridgeToolNames = map[string]bool{
// 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).
func NewBridgeServer(reg *tools.Registry, version string) *mcpserver.StreamableHTTPServer {
// msgBus is optional; when non-nil, tools that produce media (deliver:true) will
// publish file attachments directly to the outbound bus.
func NewBridgeServer(reg *tools.Registry, version string, msgBus *bus.MessageBus) *mcpserver.StreamableHTTPServer {
srv := mcpserver.NewMCPServer("goclaw-bridge", version,
mcpserver.WithToolCapabilities(false),
)
@@ -60,7 +68,7 @@ func NewBridgeServer(reg *tools.Registry, version string) *mcpserver.StreamableH
}
mcpTool := convertToMCPTool(t)
handler := makeToolHandler(reg, name)
handler := makeToolHandler(reg, name, msgBus)
srv.AddTool(mcpTool, handler)
registered++
}
@@ -83,7 +91,9 @@ func convertToMCPTool(t tools.Tool) mcpgo.Tool {
}
// makeToolHandler creates a ToolHandlerFunc that delegates to the GoClaw tool registry.
func makeToolHandler(reg *tools.Registry, toolName string) mcpserver.ToolHandlerFunc {
// 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) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
args := req.GetArguments()
@@ -93,6 +103,70 @@ func makeToolHandler(reg *tools.Registry, toolName string) mcpserver.ToolHandler
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"
}
}
+31 -12
View File
@@ -2,6 +2,7 @@ package providers
import (
"log/slog"
"os"
"sync"
)
@@ -12,6 +13,21 @@ const OptSessionKey = "session_key"
// Useful for pure text generation (e.g. summoning) where tool use is unwanted.
const OptDisableTools = "disable_tools"
// OptAgentID passes the agent UUID string for per-session MCP config.
const OptAgentID = "agent_id"
// OptUserID passes the user ID string for per-session MCP config.
const OptUserID = "user_id"
// OptChannel passes the source channel (telegram, discord, etc.) for MCP bridge context.
const OptChannel = "channel"
// OptChatID passes the source chat ID for MCP bridge context.
const OptChatID = "chat_id"
// OptPeerKind passes the peer kind (direct/group) for MCP bridge context.
const OptPeerKind = "peer_kind"
// ClaudeCLIProvider implements Provider by shelling out to the `claude` CLI binary.
// It acts as a thin proxy: CLI manages session history, tool execution, and context.
// GoClaw only forwards the latest user message and streams back the response.
@@ -19,13 +35,13 @@ type ClaudeCLIProvider struct {
cliPath string // path to claude binary (default: "claude")
defaultModel string // default: "sonnet"
baseWorkDir string // base dir for agent workspaces
mcpConfigPath string // pre-built MCP config file path (empty = no MCP)
mcpConfigData *MCPConfigData // per-session MCP config data
permMode string // permission mode (default: "bypassPermissions")
hooksSettingsPath string // generated settings.json with security hooks (empty = no hooks)
hooksCleanup func() // cleanup function for hooks temp files
mcpCleanup func() // cleanup function for MCP config temp file
mu sync.Mutex // protects workdir creation
sessionMu sync.Map // key: string, value: *sync.Mutex — per-session lock
mcpConfigDirs sync.Map // key: string (dir path), value: struct{} — tracks per-session MCP config dirs for cleanup
}
// ClaudeCLIOption configures the provider.
@@ -49,13 +65,11 @@ func WithClaudeCLIWorkDir(dir string) ClaudeCLIOption {
}
}
// WithClaudeCLIMCPConfig sets the MCP config file path.
func WithClaudeCLIMCPConfig(path string, cleanup ...func()) ClaudeCLIOption {
// WithClaudeCLIMCPConfigData sets the per-session MCP config data.
// Per-session configs are written on each Chat/ChatStream call with agent context.
func WithClaudeCLIMCPConfigData(data *MCPConfigData) ClaudeCLIOption {
return func(p *ClaudeCLIProvider) {
p.mcpConfigPath = path
if len(cleanup) > 0 && cleanup[0] != nil {
p.mcpCleanup = cleanup[0]
}
p.mcpConfigData = data
}
}
@@ -104,11 +118,16 @@ func NewClaudeCLIProvider(cliPath string, opts ...ClaudeCLIOption) *ClaudeCLIPro
func (p *ClaudeCLIProvider) Name() string { return "claude-cli" }
func (p *ClaudeCLIProvider) DefaultModel() string { return p.defaultModel }
// Close cleans up temp files (MCP config, hooks settings). Implements io.Closer.
// Close cleans up temp files (per-session MCP configs, hooks settings). Implements io.Closer.
func (p *ClaudeCLIProvider) Close() error {
if p.mcpCleanup != nil {
p.mcpCleanup()
}
// Clean up per-session MCP config directories this provider created
p.mcpConfigDirs.Range(func(key, _ any) bool {
dir := key.(string)
if err := os.RemoveAll(dir); err != nil {
slog.Warn("claude-cli: failed to clean mcp config dir", "dir", dir, "error", err)
}
return true
})
if p.hooksCleanup != nil {
p.hooksCleanup()
}
+10 -6
View File
@@ -17,7 +17,7 @@ import (
// Chat runs the CLI synchronously and returns the final response.
func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractSessionKey(req.Options)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
@@ -35,8 +35,10 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRes
}
cliSessionID := deriveSessionUUID(sessionKey)
disableTools := extractDisableTools(req.Options)
args := p.buildArgs(model, workDir, cliSessionID, "json", len(images) > 0, disableTools)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "json", len(images) > 0, disableTools)
var stdin *bytes.Reader
if len(images) > 0 {
@@ -67,7 +69,7 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ChatRequest) (*ChatRes
// ChatStream runs the CLI with stream-json output, calling onChunk for each text delta.
func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
systemPrompt, userMsg, images := extractFromMessages(req.Messages)
sessionKey := extractSessionKey(req.Options)
sessionKey := extractStringOpt(req.Options, OptSessionKey)
model := req.Model
if model == "" {
model = p.defaultModel
@@ -90,8 +92,10 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ChatRequest, onC
}
cliSessionID := deriveSessionUUID(sessionKey)
disableToolsStream := extractDisableTools(req.Options)
args := p.buildArgs(model, workDir, cliSessionID, "stream-json", len(images) > 0, disableToolsStream)
disableTools := extractBoolOpt(req.Options, OptDisableTools)
bc := bridgeContextFromOpts(req.Options)
mcpPath := p.resolveMCPConfigPath(ctx, sessionKey, bc)
args := p.buildArgs(model, workDir, mcpPath, cliSessionID, "stream-json", len(images) > 0, disableTools)
var stdin *bytes.Reader
if len(images) > 0 {
+229 -71
View File
@@ -1,110 +1,268 @@
package providers
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// BuildCLIMCPConfig exports GoClaw's MCP server configs into a temp JSON file
// compatible with Claude CLI's --mcp-config flag.
// When gatewayAddr is non-empty, a "goclaw-bridge" entry is injected so CLI
// can call GoClaw's built-in tools via streamable-http MCP transport.
// Returns the file path and a cleanup function. Caller must call cleanup when done.
func BuildCLIMCPConfig(servers map[string]*config.MCPServerConfig, gatewayAddr string, gatewayToken ...string) (string, func(), error) {
mcpServers := make(map[string]interface{}, len(servers)+1)
// MCPServerEntry represents a single MCP server config for CLI injection.
type MCPServerEntry struct {
Name string
Transport string // "stdio", "sse", "streamable-http"
Command string
Args []string
URL string
Headers map[string]string
Env map[string]string
}
// MCPServerLookup returns accessible MCP servers for a given agent ID.
// Used to inject per-agent DB-backed MCP servers into CLI MCP config.
type MCPServerLookup func(ctx context.Context, agentID string) []MCPServerEntry
// MCPConfigData holds the base MCP server entries built at startup.
// Per-session configs are written via WriteMCPConfig with agent context injected.
type MCPConfigData struct {
Servers map[string]interface{} // external MCP server entries (stdio/sse/http)
GatewayAddr string
GatewayToken string
AgentMCPLookup MCPServerLookup // optional: resolves per-agent MCP servers from DB
}
// BuildCLIMCPConfigData builds the base MCP server map from config.
// Does NOT include the goclaw-bridge entry — that's added per-session
// with agent context headers in WriteMCPConfig.
func BuildCLIMCPConfigData(servers map[string]*config.MCPServerConfig, gatewayAddr string, gatewayToken ...string) *MCPConfigData {
mcpServers := make(map[string]interface{}, len(servers))
for name, srv := range servers {
if !srv.IsEnabled() {
continue
}
entry := make(map[string]interface{})
switch srv.Transport {
case "stdio":
if srv.Command != "" {
entry["command"] = srv.Command
}
if len(srv.Args) > 0 {
entry["args"] = srv.Args
}
if len(srv.Env) > 0 {
entry["env"] = srv.Env
}
case "sse":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "sse"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
case "streamable-http":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "http"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
default:
continue
}
entry := mcpServerEntryToConfig(MCPServerEntry{
Name: name,
Transport: srv.Transport,
Command: srv.Command,
Args: srv.Args,
URL: srv.URL,
Headers: srv.Headers,
Env: srv.Env,
})
if len(entry) > 0 {
mcpServers[name] = entry
}
}
// Inject GoClaw bridge entry so CLI can access built-in tools via MCP
if gatewayAddr != "" {
bridgeEntry := map[string]interface{}{
"url": fmt.Sprintf("http://%s/mcp/bridge", gatewayAddr),
"type": "http",
}
if len(gatewayToken) > 0 && gatewayToken[0] != "" {
bridgeEntry["headers"] = map[string]string{
"Authorization": "Bearer " + gatewayToken[0],
token := ""
if len(gatewayToken) > 0 {
token = gatewayToken[0]
}
return &MCPConfigData{
Servers: mcpServers,
GatewayAddr: gatewayAddr,
GatewayToken: token,
}
}
// mcpConfigBaseDir returns ~/.goclaw/mcp-configs, separate from workDir
// so agent cannot read tokens from the MCP config files.
func mcpConfigBaseDir() string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join(os.TempDir(), "goclaw-mcp-configs")
}
return filepath.Join(home, ".goclaw", "mcp-configs")
}
// BridgeContext holds per-call context for MCP bridge headers.
type BridgeContext struct {
AgentID string
UserID string
Channel string
ChatID string
PeerKind string
}
// WriteMCPConfig writes a per-session MCP config file with agent context headers.
// Files are stored at ~/.goclaw/mcp-configs/<safe-session-key>/mcp-config.json,
// outside the agent's workDir so tokens are not exposed.
// Skips write if content is unchanged. Returns the file path.
func (d *MCPConfigData) WriteMCPConfig(ctx context.Context, sessionKey string, bc BridgeContext) string {
return d.writeMCPConfigInternal(ctx, sessionKey, bc.AgentID, bc.UserID, bc.Channel, bc.ChatID, bc.PeerKind)
}
func (d *MCPConfigData) writeMCPConfigInternal(ctx context.Context, sessionKey, agentID, userID, channel, chatID, peerKind string) string {
if d == nil || (len(d.Servers) == 0 && d.GatewayAddr == "" && d.AgentMCPLookup == nil) {
return ""
}
// Shallow-copy the outer map so we can add the bridge entry without mutating the shared base.
// Inner server entries are not modified, so shallow copy is sufficient.
servers := make(map[string]interface{}, len(d.Servers)+1)
for k, v := range d.Servers {
servers[k] = v
}
// Inject per-agent MCP servers from DB (if lookup is configured and agentID is set)
if d.AgentMCPLookup != nil && agentID != "" {
for _, srv := range d.AgentMCPLookup(ctx, agentID) {
if _, exists := servers[srv.Name]; exists {
continue // don't override static/bridge entries
}
entry := mcpServerEntryToConfig(srv)
if len(entry) > 0 {
servers[srv.Name] = entry
}
}
mcpServers["goclaw-bridge"] = bridgeEntry
}
if len(mcpServers) == 0 {
return "", func() {}, nil
// Build bridge entry with per-session agent context headers
if d.GatewayAddr != "" {
headers := make(map[string]string)
if d.GatewayToken != "" {
headers["Authorization"] = "Bearer " + d.GatewayToken
}
if agentID != "" && !strings.ContainsAny(agentID, "\r\n\x00") {
headers["X-Agent-ID"] = agentID
}
if userID != "" && !strings.ContainsAny(userID, "\r\n\x00") {
headers["X-User-ID"] = userID
}
if channel != "" && !strings.ContainsAny(channel, "\r\n\x00") {
headers["X-Channel"] = channel
}
if chatID != "" && !strings.ContainsAny(chatID, "\r\n\x00") {
headers["X-Chat-ID"] = chatID
}
if peerKind != "" && !strings.ContainsAny(peerKind, "\r\n\x00") {
headers["X-Peer-Kind"] = peerKind
}
// HMAC signature over all context fields to prevent header forgery
if d.GatewayToken != "" && (agentID != "" || userID != "") {
headers["X-Bridge-Sig"] = SignBridgeContext(d.GatewayToken, agentID, userID, channel, chatID, peerKind)
}
bridgeEntry := map[string]interface{}{
"url": fmt.Sprintf("http://%s/mcp/bridge", d.GatewayAddr),
"type": "http",
}
if len(headers) > 0 {
bridgeEntry["headers"] = headers
}
servers["goclaw-bridge"] = bridgeEntry
}
cfg := map[string]interface{}{
"mcpServers": mcpServers,
if len(servers) == 0 {
return ""
}
data, err := json.MarshalIndent(cfg, "", " ")
data, err := json.MarshalIndent(map[string]interface{}{"mcpServers": servers}, "", " ")
if err != nil {
return "", nil, fmt.Errorf("marshal mcp config: %w", err)
slog.Warn("claude-cli: failed to marshal mcp config", "error", err)
return ""
}
// Write to a temp file with restricted permissions
tmpDir := filepath.Join(os.TempDir(), "goclaw-mcp-configs")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return "", nil, fmt.Errorf("create mcp config dir: %w", err)
// Write to per-session dir outside workDir
safe := sanitizePathSegment(sessionKey)
dir := filepath.Join(mcpConfigBaseDir(), safe)
if err := os.MkdirAll(dir, 0700); err != nil {
slog.Warn("claude-cli: failed to create mcp config dir", "error", err)
return ""
}
tmpFile := filepath.Join(tmpDir, fmt.Sprintf("mcp-%s.json", uuid.New().String()[:8]))
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return "", nil, fmt.Errorf("write mcp config: %w", err)
path := filepath.Join(dir, "mcp-config.json")
// Skip write if unchanged
if existing, err := os.ReadFile(path); err == nil && string(existing) == string(data) {
return path
}
// Atomic write: temp file + rename to prevent partial reads
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
slog.Warn("claude-cli: failed to write mcp config tmp", "error", err)
return ""
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
slog.Warn("claude-cli: failed to rename mcp config", "error", err)
return ""
}
cleanup := func() {
os.Remove(tmpFile)
return path
}
// mcpServerEntryToConfig converts an MCPServerEntry to the CLI MCP config format.
func mcpServerEntryToConfig(srv MCPServerEntry) map[string]interface{} {
entry := make(map[string]interface{})
switch srv.Transport {
case "stdio":
if srv.Command != "" {
entry["command"] = srv.Command
}
if len(srv.Args) > 0 {
entry["args"] = srv.Args
}
if len(srv.Env) > 0 {
entry["env"] = srv.Env
}
case "sse":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "sse"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
case "streamable-http":
if srv.URL != "" {
entry["url"] = srv.URL
entry["type"] = "http"
}
if len(srv.Headers) > 0 {
entry["headers"] = srv.Headers
}
}
return entry
}
// sanitizePathSegment makes a string safe for use as a single filesystem directory name.
// Replaces path separators and special chars, strips null bytes, handles ".." traversal,
// and truncates to 255 chars.
func sanitizePathSegment(s string) string {
safe := strings.NewReplacer(":", "-", "/", "-", "\\", "-", "\x00", "").Replace(s)
// Collapse any ".." sequences to prevent traversal
safe = strings.ReplaceAll(safe, "..", "_")
if len(safe) > 255 {
safe = safe[:255]
}
if safe == "" || safe == "." {
safe = "default"
}
return safe
}
// SignBridgeContext computes HMAC-SHA256 over all bridge context fields to prevent forgery.
// Payload: agentID|userID|channel|chatID|peerKind
func SignBridgeContext(key, agentID, userID, channel, chatID, peerKind string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(agentID + "|" + userID + "|" + channel + "|" + chatID + "|" + peerKind))
return hex.EncodeToString(mac.Sum(nil))
}
return tmpFile, cleanup, nil
// VerifyBridgeContext checks the HMAC signature against the expected bridge context.
func VerifyBridgeContext(key, agentID, userID, channel, chatID, peerKind, sig string) bool {
expected := SignBridgeContext(key, agentID, userID, channel, chatID, peerKind)
return hmac.Equal([]byte(expected), []byte(sig))
}
+44 -22
View File
@@ -2,6 +2,7 @@ package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -26,7 +27,8 @@ func validateCLIModel(model string) error {
}
// buildArgs constructs CLI arguments.
func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.UUID, outputFormat string, hasImages, disableTools bool) []string {
// mcpConfigPath is the resolved per-session MCP config file (may differ per call).
func (p *ClaudeCLIProvider) buildArgs(model, workDir, mcpConfigPath string, cliSessionID uuid.UUID, outputFormat string, hasImages, disableTools bool) []string {
args := []string{
"-p",
"--output-format", outputFormat,
@@ -35,8 +37,8 @@ func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.U
"--verbose",
}
if p.mcpConfigPath != "" {
args = append(args, "--mcp-config", p.mcpConfigPath)
if mcpConfigPath != "" {
args = append(args, "--mcp-config", mcpConfigPath)
}
// Session persistence: check if CLI session file exists on disk.
@@ -56,7 +58,7 @@ func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.U
if disableTools {
// Summoner: disable all tools entirely via disallowedTools
args = append(args, "--disallowedTools", "Bash,Edit,Read,Write,Glob,Grep,WebFetch,WebSearch,TodoRead,TodoWrite,NotebookRead,NotebookEdit")
} else if p.mcpConfigPath != "" {
} else if mcpConfigPath != "" {
// Chat with MCP bridge: disable CLI built-in tools, only allow MCP bridge tools.
// This ensures all tool execution goes through GoClaw's controlled MCP bridge.
args = append(args, "--disallowedTools", "Bash,Edit,Read,Write,Glob,Grep,WebFetch,WebSearch,TodoRead,TodoWrite,NotebookRead,NotebookEdit")
@@ -69,13 +71,22 @@ func (p *ClaudeCLIProvider) buildArgs(model, workDir string, cliSessionID uuid.U
return args
}
// resolveMCPConfigPath writes a per-session MCP config with agent context and returns its path.
func (p *ClaudeCLIProvider) resolveMCPConfigPath(ctx context.Context, sessionKey string, bc BridgeContext) string {
if p.mcpConfigData == nil {
return ""
}
path := p.mcpConfigData.WriteMCPConfig(ctx, sessionKey, bc)
if path != "" {
p.mcpConfigDirs.Store(filepath.Dir(path), struct{}{})
}
return path
}
// ensureWorkDir creates and returns a stable work directory for the given session key.
func (p *ClaudeCLIProvider) ensureWorkDir(sessionKey string) string {
if sessionKey == "" {
sessionKey = "default"
}
// Sanitize session key for filesystem
safe := strings.NewReplacer(":", "-", "/", "-", "\\", "-").Replace(sessionKey)
// Sanitize session key for filesystem safety (path traversal, null bytes, length)
safe := sanitizePathSegment(sessionKey)
dir := filepath.Join(p.baseWorkDir, safe)
p.mu.Lock()
@@ -119,12 +130,25 @@ func extractFromMessages(msgs []Message) (systemPrompt, userMsg string, images [
return
}
// extractDisableTools checks if disable_tools is set to true in Options.
func extractDisableTools(opts map[string]interface{}) bool {
// extractStringOpt gets a string value from Options map by key.
func extractStringOpt(opts map[string]interface{}, key string) string {
if opts == nil {
return ""
}
if v, ok := opts[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// extractBoolOpt gets a bool value from Options map by key.
func extractBoolOpt(opts map[string]interface{}, key string) bool {
if opts == nil {
return false
}
if v, ok := opts[OptDisableTools]; ok {
if v, ok := opts[key]; ok {
if b, ok := v.(bool); ok {
return b
}
@@ -132,17 +156,15 @@ func extractDisableTools(opts map[string]interface{}) bool {
return false
}
// extractSessionKey gets session_key from Options map.
func extractSessionKey(opts map[string]interface{}) string {
if opts == nil {
return ""
// bridgeContextFromOpts builds a BridgeContext from the Options map.
func bridgeContextFromOpts(opts map[string]interface{}) BridgeContext {
return BridgeContext{
AgentID: extractStringOpt(opts, OptAgentID),
UserID: extractStringOpt(opts, OptUserID),
Channel: extractStringOpt(opts, OptChannel),
ChatID: extractStringOpt(opts, OptChatID),
PeerKind: extractStringOpt(opts, OptPeerKind),
}
if v, ok := opts[OptSessionKey]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// defaultCLIWorkDir returns ~/.goclaw/cli-workspaces, falling back to temp dir.