diff --git a/cmd/gateway.go b/cmd/gateway.go index e35ecc37..060f7dee 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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. diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 7bd9da80..29901305 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -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 { diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 238cdbb7..1261af07 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -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) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index d989af5c..3d42d1b9 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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" { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 1a8e551b..00dd1d12 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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 } diff --git a/internal/http/providers.go b/internal/http/providers.go index f8ad9de9..809d9b5a 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -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 { diff --git a/internal/mcp/bridge_server.go b/internal/mcp/bridge_server.go index 106fc389..e773dc27 100644 --- a/internal/mcp/bridge_server.go +++ b/internal/mcp/bridge_server.go @@ -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" + } +} diff --git a/internal/providers/claude_cli.go b/internal/providers/claude_cli.go index e42c70b8..92f6abcb 100644 --- a/internal/providers/claude_cli.go +++ b/internal/providers/claude_cli.go @@ -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() } diff --git a/internal/providers/claude_cli_chat.go b/internal/providers/claude_cli_chat.go index 8327eaab..8acc8025 100644 --- a/internal/providers/claude_cli_chat.go +++ b/internal/providers/claude_cli_chat.go @@ -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 { diff --git a/internal/providers/claude_cli_mcp.go b/internal/providers/claude_cli_mcp.go index 68db68fa..ff8966ea 100644 --- a/internal/providers/claude_cli_mcp.go +++ b/internal/providers/claude_cli_mcp.go @@ -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//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)) } diff --git a/internal/providers/claude_cli_session.go b/internal/providers/claude_cli_session.go index dac803ce..ecd8fcfb 100644 --- a/internal/providers/claude_cli_session.go +++ b/internal/providers/claude_cli_session.go @@ -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.