mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
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:
11 files changed
+567
-146
No files matched your search
+2
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user