diff --git a/cmd/gateway.go b/cmd/gateway.go index 235ada1a..80364ae6 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -491,6 +491,7 @@ func runGateway() { server.SetToolPolicy(toolPE) server.SetPairingService(pgStores.Pairing) server.SetMessageBus(msgBus) + server.SetExecApprovalManager(execApprovalMgr) server.SetOAuthHandler(httpapi.NewOAuthHandler(pgStores.Providers, pgStores.ConfigSecrets, providerRegistry, msgBus)) // contextFileInterceptor is created inside wireExtras. @@ -502,6 +503,42 @@ func runGateway() { if pgStores.Agents != nil { server.SetAgentStore(pgStores.Agents) } + // Wire the skill/cron stores used by the CRUD MCP server (see + // internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()). + if pgStores.Skills != nil { + server.SetSkillStore(pgStores.Skills) + } + if pgStores.Cron != nil { + server.SetCronStore(pgStores.Cron) + } + if pgStores.AgentLinks != nil { + server.SetAgentLinkStore(pgStores.AgentLinks) + } + if pgStores.ConfigPermissions != nil { + server.SetConfigPermissionStore(pgStores.ConfigPermissions) + } + if pgStores.BitrixPortals != nil { + server.SetBitrixPortalStore(pgStores.BitrixPortals) + } + if pgStores.RunTimeline != nil { + server.SetRunTimelineStore(pgStores.RunTimeline) + } + if pgStores.Teams != nil { + server.SetTeamStore(pgStores.Teams) + } + if pgStores.ChannelInstances != nil { + server.SetChannelInstanceStore(pgStores.ChannelInstances) + } + if pgStores.Heartbeats != nil { + server.SetHeartbeatStore(pgStores.Heartbeats) + } + if pgStores.Providers != nil { + server.SetProviderStore(pgStores.Providers) + } + if pgStores.Tenants != nil { + server.SetTenantStore(pgStores.Tenants) + } + server.SetSQLDB(pgStores.DB) // Build OAuth token refresher before wireExtras so the resolver can inject tokens. var mcpOAuthRefresher mcpbridge.OAuthTokenProvider @@ -654,6 +691,7 @@ func runGateway() { hm.SetTestRunner(methods.NewDispatcherTestRunner(sharedHookHandlers)) } hm.Register(server.Router()) + server.SetHookStore(hs) slog.Info("registered hooks RPC methods") } @@ -696,6 +734,7 @@ func runGateway() { channelMgr := channels.NewManager(msgBus) channelMgr.SetSystemMessages(systemmessages.NewResolver(cfg)) deps.channelMgr = channelMgr + server.SetChannelManager(channelMgr) // Wire channel member resolver into permission grant paths (WS + HTTP) so // file_writer grants coming from the Web UI auto-enrich their metadata. @@ -939,6 +978,7 @@ func runGateway() { // Register quota usage RPC. methods.NewQuotaMethods(quotaChecker, pgStores.DB).Register(server.Router()) + server.SetQuotaChecker(quotaChecker) // API key management RPC if pgStores.APIKeys != nil { diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index c5d180ac..d8a21a97 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -346,6 +346,9 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // Wire WS method — provider nil means each request resolves key via secretStore at HTTP layer. // For WS, use same cache. Provider is resolved via secretStore at WS level in a future phase. methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router()) + // Wire the same cache + secret store into the CRUD MCP server (see + // internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()). + d.server.SetVoiceCache(voiceCache, secretStore) } // TTS synthesize endpoint — shares audio.Manager with setupTTS. diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index afd3f4da..63e61efe 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -69,6 +69,9 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore // Phase 2: Usage (queries SessionStore for real token data) methods.NewUsageMethods(sessStore, tracingStore).Register(router) methods.NewLLMMethods(providerReg, cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel).Register(router) + // Wire the same provider registry into the CRUD MCP server (see + // internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()). + server.SetLLMProviders(providerReg, cfg.Gateway.BackgroundProvider, cfg.Gateway.BackgroundModel) // Phase 2: Exec approval (always registered — returns empty when manager is nil) methods.NewExecApprovalMethods(execApprovalMgr, msgBus).Register(router) diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 5c6c615e..b3855436 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -426,6 +426,7 @@ type GatewayConfig struct { Host string `json:"host"` Port int `json:"port"` Token string `json:"token,omitempty"` // bearer token for WS/HTTP auth + MCPServerToken string `json:"mcp_server_token,omitempty"` // bearer token gating the CRUD MCP server mounted at /api/mcp/; callers may pass an optional "X-GoClaw-Tenant-Id" header (UUID or slug) to scope a request to a tenant, defaulting to the master tenant when absent (see internal/mcp/crud_server.go) OwnerIDs []string `json:"owner_ids,omitempty"` // sender IDs considered "owner" AllowedOrigins []string `json:"allowed_origins,omitempty"` // WebSocket CORS whitelist (empty = allow all) MCPAllowedHosts []string `json:"mcp_allowed_hosts,omitempty"` // trusted MCP server hostnames exempt from the private-IP SSRF block during config validation (empty = none) diff --git a/internal/config/config_load.go b/internal/config/config_load.go index dcc5febf..a6befd5c 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -184,6 +184,7 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_VERTEX_REGION", &c.Providers.Vertex.Region) envStr("GOCLAW_VERTEX_MODEL", &c.Providers.Vertex.Model) envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token) + envStr("GOCLAW_MCP_SERVER_TOKEN", &c.Gateway.MCPServerToken) envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token) envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token) envStr("GOCLAW_ZALO_TOKEN", &c.Channels.Zalo.Token) diff --git a/internal/config/config_secrets.go b/internal/config/config_secrets.go index 99e6a8a0..49d02eed 100644 --- a/internal/config/config_secrets.go +++ b/internal/config/config_secrets.go @@ -41,6 +41,7 @@ func (c *Config) MaskedCopy() *Config { // Mask gateway token maskNonEmpty(&cp.Gateway.Token) + maskNonEmpty(&cp.Gateway.MCPServerToken) // Mask channel secrets maskNonEmpty(&cp.Channels.Telegram.Token) @@ -89,6 +90,7 @@ func (c *Config) StripSecrets() { // Gateway token c.Gateway.Token = "" + c.Gateway.MCPServerToken = "" // Channel secrets c.Channels.Telegram.Token = "" @@ -142,6 +144,7 @@ func (c *Config) StripMaskedSecrets() { // Gateway token stripIfMasked(&c.Gateway.Token) + stripIfMasked(&c.Gateway.MCPServerToken) // Channel secrets stripIfMasked(&c.Channels.Telegram.Token) @@ -175,6 +178,7 @@ func (c *Config) ApplyDBSecrets(secrets map[string]string) { } apply("gateway.token", &c.Gateway.Token) + apply("gateway.mcp_server_token", &c.Gateway.MCPServerToken) apply("tts.openai.api_key", &c.Tts.OpenAI.APIKey) apply("tts.elevenlabs.api_key", &c.Tts.ElevenLabs.APIKey) apply("tts.minimax.api_key", &c.Tts.MiniMax.APIKey) @@ -194,6 +198,7 @@ func (c *Config) ExtractDBSecrets() map[string]string { } collect("gateway.token", c.Gateway.Token) + collect("gateway.mcp_server_token", c.Gateway.MCPServerToken) collect("tts.openai.api_key", c.Tts.OpenAI.APIKey) collect("tts.elevenlabs.api_key", c.Tts.ElevenLabs.APIKey) collect("tts.minimax.api_key", c.Tts.MiniMax.APIKey) diff --git a/internal/gateway/chat_runner.go b/internal/gateway/chat_runner.go new file mode 100644 index 00000000..130a0232 --- /dev/null +++ b/internal/gateway/chat_runner.go @@ -0,0 +1,138 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" + mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" + "github.com/nextlevelbuilder/goclaw/internal/sessions" +) + +// agentChatRunner implements mcpbridge.ChatRunner against the live +// *agent.Router, backing the CRUD MCP server's goclaw_chat_* tools +// (see internal/mcp/crud_chat.go). It intentionally omits the WS-only +// concerns handled by ChatMethods (internal/gateway/methods/chat.go): rate +// limiting, send debouncing, and per-WS-client session ownership checks. The +// MCP bearer token (gateway.mcp_server_token) is the sole security boundary +// for this surface, same as the rest of the CRUD MCP server. +type agentChatRunner struct { + agents *agent.Router +} + +// Send runs the agent synchronously and returns the final result. Always +// non-streaming: MCP tool calls are request/response, so there is no channel +// to forward incremental run events to the caller. +func (r *agentChatRunner) Send(ctx context.Context, agentID, sessionKey, message string, mediaItems []mcpbridge.ChatMediaItem) (*mcpbridge.ChatSendResult, error) { + if agentID == "" { + if sessionKey != "" { + if parsedAgentID, _ := sessions.ParseSessionKey(sessionKey); parsedAgentID != "" { + agentID = parsedAgentID + } + } + if agentID == "" { + agentID = "default" + } + } + + loop, err := r.agents.Get(ctx, agentID) + if err != nil { + return nil, fmt.Errorf("get agent %q: %w", agentID, err) + } + + if sessionKey == "" { + sessionKey = sessions.BuildWSSessionKey(agentID, uuid.NewString()) + } + + var mediaFiles []bus.MediaFile + for _, item := range mediaItems { + mediaFiles = append(mediaFiles, bus.MediaFile{ + Path: item.Path, + MimeType: media.DetectMIMEType(item.Path), + Filename: item.Filename, + }) + } + + runID := uuid.NewString() + result, err := loop.Run(ctx, agent.RunRequest{ + SessionKey: sessionKey, + Message: message, + Media: mediaFiles, + Channel: "mcp", + RunID: runID, + }) + if err != nil { + if ctx.Err() != nil { + return &mcpbridge.ChatSendResult{Cancelled: true}, nil + } + return nil, fmt.Errorf("run agent %q: %w", agentID, err) + } + + return &mcpbridge.ChatSendResult{ + RunID: result.RunID, + Content: result.Content, + Usage: result.Usage, + Thinking: result.Thinking, + Media: result.Media, + }, nil +} + +// Abort cancels the run(s) matching runID and/or sessionKey, mirroring +// ChatMethods.handleAbort's aggregation logic (minus the caller-identity +// unauthorized-vs-notfound collapsing, which requires a WS client role). +func (r *agentChatRunner) Abort(_ context.Context, runID, sessionKey string) (*mcpbridge.ChatAbortResult, error) { + var results []agent.AbortResult + if runID != "" { + results = []agent.AbortResult{r.agents.AbortRun(runID, sessionKey)} + } else { + results = r.agents.AbortRunsForSession(sessionKey) + } + + var runIDs []string + stopped, forced, alreadyAborting, notFound := 0, 0, 0, 0 + for _, res := range results { + runIDs = append(runIDs, res.RunID) + switch { + case res.Stopped: + stopped++ + case res.Forced: + forced++ + case res.AlreadyAborting: + alreadyAborting++ + case res.NotFound, res.Unauthorized: + notFound++ + } + } + + return &mcpbridge.ChatAbortResult{ + OK: true, + Aborted: stopped+forced > 0, + Stopped: stopped > 0, + Forced: forced > 0, + AlreadyAborting: alreadyAborting > 0, + NotFound: notFound > 0 && stopped+forced+alreadyAborting == 0, + RunIDs: runIDs, + }, nil +} + +// SessionStatus reports the running state and activity for a session. +func (r *agentChatRunner) SessionStatus(_ context.Context, sessionKey string) (*mcpbridge.ChatSessionStatusResult, error) { + result := &mcpbridge.ChatSessionStatusResult{ + IsRunning: r.agents.IsSessionBusy(sessionKey), + } + if runID, ok := r.agents.SessionRunID(sessionKey); ok { + result.RunID = runID + } + if status := r.agents.GetActivity(sessionKey); status != nil { + result.Activity = &mcpbridge.ChatActivity{ + Phase: status.Phase, + Tool: status.Tool, + Iteration: status.Iteration, + } + } + return result, nil +} diff --git a/internal/gateway/chat_runner_test.go b/internal/gateway/chat_runner_test.go new file mode 100644 index 00000000..c97bd0e1 --- /dev/null +++ b/internal/gateway/chat_runner_test.go @@ -0,0 +1,63 @@ +package gateway + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/agent" +) + +// TestAgentChatRunner_Send_UnknownAgent_ReturnsWrappedError exercises the +// error path only: agentChatRunner.Send needs a live *agent.Router with a +// resolver (DB-backed agent lookup + provider wiring) to reach a real LLM +// call, which is out of scope for a unit test — full happy-path coverage of +// Send lives in internal/agent's own Router/Loop test suite. This verifies +// the adapter's own responsibilities: default agentID resolution ("default" +// when empty) and error wrapping when the underlying agent can't be +// resolved (no resolver configured on a bare Router). +func TestAgentChatRunner_Send_UnknownAgent_ReturnsWrappedError(t *testing.T) { + router := agent.NewRouter() + runner := &agentChatRunner{agents: router} + + result, err := runner.Send(context.Background(), "", "", "hello", nil) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), `get agent "default"`) +} + +// TestAgentChatRunner_Abort_NoActiveRuns_ReportsNotFound verifies the +// aggregation logic in Abort when there is nothing to abort, both by runID +// and by sessionKey. +func TestAgentChatRunner_Abort_NoActiveRuns_ReportsNotFound(t *testing.T) { + router := agent.NewRouter() + runner := &agentChatRunner{agents: router} + + byRunID, err := runner.Abort(context.Background(), "run-does-not-exist", "") + require.NoError(t, err) + assert.True(t, byRunID.OK) + assert.False(t, byRunID.Aborted) + assert.True(t, byRunID.NotFound) + + bySessionKey, err := runner.Abort(context.Background(), "", "session-does-not-exist") + require.NoError(t, err) + assert.True(t, bySessionKey.OK) + assert.False(t, bySessionKey.Aborted) + assert.Empty(t, bySessionKey.RunIDs) +} + +// TestAgentChatRunner_SessionStatus_IdleSession verifies the adapter reports +// a non-running status (with no activity/runID) for a session that never had +// a run started against this Router. +func TestAgentChatRunner_SessionStatus_IdleSession(t *testing.T) { + router := agent.NewRouter() + runner := &agentChatRunner{agents: router} + + result, err := runner.SessionStatus(context.Background(), "never-run-session") + require.NoError(t, err) + assert.False(t, result.IsRunning) + assert.Empty(t, result.RunID) + assert.Nil(t, result.Activity) +} diff --git a/internal/gateway/mcp_server_token_auth_test.go b/internal/gateway/mcp_server_token_auth_test.go new file mode 100644 index 00000000..75c778b8 --- /dev/null +++ b/internal/gateway/mcp_server_token_auth_test.go @@ -0,0 +1,112 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// ---- mcpServerTokenAuthMiddleware ---- +// +// Gates the CRUD MCP server (/api/mcp/) with its own bearer token, independent +// from tokenAuthMiddleware's general gateway token (see server.go). Mirrors +// TestTokenAuthMiddleware_* above; kept separate since the two middlewares +// are intentionally distinct code paths (different security logging) even +// though their pass/fail logic is currently identical. + +func TestMCPServerTokenAuthMiddleware_ValidToken_PassesThrough(t *testing.T) { + token := "mcp-secret" + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := mcpServerTokenAuthMiddleware(token, next) + + req := httptest.NewRequest(http.MethodPost, "/api/mcp/", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if !called { + t.Error("next handler should have been called with valid token") + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestMCPServerTokenAuthMiddleware_WrongToken_Returns401(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := mcpServerTokenAuthMiddleware("correct-token", next) + + req := httptest.NewRequest(http.MethodPost, "/api/mcp/", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } + // Note: http.Error() (used internally) always overwrites Content-Type to + // text/plain regardless of what the handler set beforehand — this is a + // stdlib quirk, not a bug in mcpServerTokenAuthMiddleware, so we only + // assert on the body content here rather than the header. + if body := w.Body.String(); body == "" { + t.Error("expected a non-empty error body") + } +} + +func TestMCPServerTokenAuthMiddleware_MissingAuthHeader_Returns401(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := mcpServerTokenAuthMiddleware("some-token", next) + + req := httptest.NewRequest(http.MethodPost, "/api/mcp/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestMCPServerTokenAuthMiddleware_NonBearerScheme_Returns401(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := mcpServerTokenAuthMiddleware("token123", next) + + req := httptest.NewRequest(http.MethodPost, "/api/mcp/", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +// TestMCPServerTokenAuthMiddleware_DistinctFromGatewayToken proves the two +// bearer tokens are independent: a request bearing the *general* gateway +// token must NOT satisfy the MCP server's own token gate. +func TestMCPServerTokenAuthMiddleware_DistinctFromGatewayToken(t *testing.T) { + gatewayToken := "gateway-token" + mcpToken := "mcp-token" + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := mcpServerTokenAuthMiddleware(mcpToken, next) + + req := httptest.NewRequest(http.MethodPost, "/api/mcp/", nil) + req.Header.Set("Authorization", "Bearer "+gatewayToken) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 (gateway token must not unlock the MCP server)", w.Code) + } +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 9ead51aa..c5c354fd 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -3,6 +3,7 @@ package gateway import ( "context" "crypto/subtle" + "database/sql" "errors" "fmt" "log/slog" @@ -17,8 +18,11 @@ import ( "github.com/gorilla/websocket" "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/hooks" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/permissions" @@ -52,9 +56,34 @@ type Server struct { pairingService store.PairingStore apiKeyStore store.APIKeyStore // for API key auth lookup agentStore store.AgentStore // for context injection in tools_invoke + skillStore store.SkillStore // for the CRUD MCP server (/api/mcp/) skill tools + cronStore store.CronStore // for the CRUD MCP server (/api/mcp/) cron tools msgBus *bus.MessageBus // for MCP bridge media delivery toolPolicy *tools.PolicyEngine // for per-agent tool policy enforcement in MCP bridge + // Additional CRUD MCP server (/api/mcp/) dependencies — all optional, same + // degrade-gracefully contract as skillStore/cronStore above. + agentLinkStore store.AgentLinkStore + configPermStore store.ConfigPermissionStore + bitrixStore store.BitrixPortalStore + runTimelineStore store.RunTimelineStore + teamStore store.TeamStore + channelInstStore store.ChannelInstanceStore + channelMgr *channels.Manager + hookStore hooks.HookStore + heartbeatStore store.HeartbeatStore + providerStore store.ProviderStore + execApprovalMgr *tools.ExecApprovalManager + quotaChecker *channels.QuotaChecker + sqlDB *sql.DB // for the CRUD MCP server's quota usage tool (today's trace summary) + tenantStore store.TenantStore // for the CRUD MCP server's "X-GoClaw-Tenant-Id" header resolution + + // Phase 3 CRUD MCP server (/api/mcp/) dependencies: chat/LLM/logs/send/voices. + llmProviders *providers.Registry + llmDefaults mcpbridge.LLMDefaults + voiceCache *audio.VoiceCache + voiceSecretsStore store.ConfigSecretsStore + upgrader websocket.Upgrader rateLimiter *RateLimiter clients map[string]*Client @@ -222,6 +251,78 @@ func (s *Server) BuildMux() *http.ServeMux { } } + // CRUD MCP server: exposes goclaw's agents/sessions/skills/cron/config + // resource management as MCP tools, backed directly by the real stores. + // Distinct from /mcp/bridge (agent tool bridge for Claude CLI) above — + // this one is meant for external automation/admin clients. Gated by its + // own bearer token (gateway.mcp_server_token / GOCLAW_MCP_SERVER_TOKEN), + // independent from the general gateway token, so it can be rotated or + // disabled separately. When unset, the server is not constructed and the + // route is not mounted at all (no 403 handler either) — the endpoint + // simply does not exist, so it can never leak the fact that a CRUD MCP + // surface is present on this deployment. + // + // Mounted under /api/mcp/ (NOT /mcp/) -- the web UI owns the client-side + // route "/mcp" (see ui/web/src/lib/routes.ts) for its "manage external + // MCP servers" page. http.ServeMux resolves the longest matching pattern + // first, so a subtree registration at "/mcp/" would shadow the SPA + // catch-all on every full page load/refresh of that route (the browser + // requests "/mcp" directly from the backend, bypassing React Router + // entirely). /api/mcp/ lives in a namespace no frontend route will ever + // occupy, so it can never collide with future client-side routes either. + if s.cfg.Gateway.MCPServerToken != "" { + var agentRuntime mcpbridge.AgentRuntimeLookup + var chatRunner mcpbridge.ChatRunner + if s.agents != nil { + agentRuntime = func(ctx context.Context, agentID string) (string, bool, error) { + loop, err := s.agents.Get(ctx, agentID) + if err != nil { + return "", false, fmt.Errorf("get agent %q: %w", agentID, err) + } + return loop.ID(), loop.IsRunning(), nil + } + chatRunner = &agentChatRunner{agents: s.agents} + } + var runtimeLogs mcpbridge.RuntimeLogSnapshotter + if s.logTee != nil { + runtimeLogs = s.logTee + } + crudHandler := mcpbridge.NewCRUDServer(mcpbridge.CRUDDeps{ + Agents: s.agentStore, + AgentRuntime: agentRuntime, + Sessions: s.sessions, + Skills: s.skillStore, + Cron: s.cronStore, + Config: s.cfg, + AgentLinks: s.agentLinkStore, + APIKeys: s.apiKeyStore, + ConfigPermissions: s.configPermStore, + Bitrix: s.bitrixStore, + RunTimeline: s.runTimelineStore, + Teams: s.teamStore, + ChannelInstances: s.channelInstStore, + ChannelManager: s.channelMgr, + Hooks: s.hookStore, + Heartbeats: s.heartbeatStore, + Providers: s.providerStore, + Pairing: s.pairingService, + ExecApproval: s.execApprovalMgr, + Quota: s.quotaChecker, + DB: s.sqlDB, + ChatRunner: chatRunner, + LLMProviders: s.llmProviders, + LLMDefaults: s.llmDefaults, + MessageBus: s.msgBus, + RuntimeLogs: runtimeLogs, + VoiceCache: s.voiceCache, + VoiceSecretsStore: s.voiceSecretsStore, + Tenants: s.tenantStore, + }, s.version) + mux.Handle("/api/mcp/", mcpServerTokenAuthMiddleware(s.cfg.Gateway.MCPServerToken, crudHandler)) + } else { + slog.Info("mcp.crud_disabled: no gateway.mcp_server_token configured, CRUD MCP server is not mounted at /api/mcp/") + } + // Embedded web UI (built with -tags embedui). Catch-all after all API routes. // When the build does NOT include the embedui tag, webui.Handler() returns nil // and there's no handler for "/" — http.ServeMux would then return an opaque @@ -358,6 +459,24 @@ func tokenAuthMiddleware(token string, next http.Handler) http.Handler { }) } +// mcpServerTokenAuthMiddleware gates the CRUD MCP server (/api/mcp/) with its own +// bearer token, distinct from the general gateway token used elsewhere. +// Auth failures are logged as security events (missing/invalid token), never +// silently dropped, so operators can spot brute-force or misconfiguration. +func mcpServerTokenAuthMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + provided := strings.TrimPrefix(auth, "Bearer ") + if !strings.HasPrefix(auth, "Bearer ") || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + slog.Warn("security.mcp_auth_failed", "path", r.URL.Path, "remote", r.RemoteAddr) + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + // Start begins listening for WebSocket and HTTP connections. func (s *Server) Start(ctx context.Context) error { mux := s.BuildMux() @@ -693,6 +812,90 @@ 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 } +// SetSkillStore sets the skill store, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to expose skill listing/lookup tools. +func (s *Server) SetSkillStore(ss store.SkillStore) { s.skillStore = ss } + +// SetCronStore sets the cron store, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to expose cron job CRUD tools. +func (s *Server) SetCronStore(cs store.CronStore) { s.cronStore = cs } + +// SetAgentLinkStore sets the agent link store, used by the CRUD MCP server +// (see internal/mcp/crud_server.go) to expose agent-link CRUD tools. +func (s *Server) SetAgentLinkStore(als store.AgentLinkStore) { s.agentLinkStore = als } + +// SetConfigPermissionStore sets the config permission store, used by the +// CRUD MCP server (see internal/mcp/crud_server.go) to expose config +// permission CRUD tools. +func (s *Server) SetConfigPermissionStore(cps store.ConfigPermissionStore) { s.configPermStore = cps } + +// SetBitrixPortalStore sets the Bitrix24 portal store, used by the CRUD MCP +// server (see internal/mcp/crud_server.go) to expose Bitrix24 portal CRUD tools. +func (s *Server) SetBitrixPortalStore(bs store.BitrixPortalStore) { s.bitrixStore = bs } + +// SetRunTimelineStore sets the run timeline store, used by the CRUD MCP +// server (see internal/mcp/crud_server.go) to expose the run timeline read tool. +func (s *Server) SetRunTimelineStore(rts store.RunTimelineStore) { s.runTimelineStore = rts } + +// SetTeamStore sets the team store, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to expose teams/tasks/workspace CRUD tools. +func (s *Server) SetTeamStore(ts store.TeamStore) { s.teamStore = ts } + +// SetChannelInstanceStore sets the channel instance store, used by the CRUD +// MCP server (see internal/mcp/crud_server.go) to expose channel instance +// CRUD tools. +func (s *Server) SetChannelInstanceStore(cis store.ChannelInstanceStore) { s.channelInstStore = cis } + +// SetChannelManager sets the channel runtime manager, used by the CRUD MCP +// server (see internal/mcp/crud_server.go) to expose channels.list/status tools. +func (s *Server) SetChannelManager(cm *channels.Manager) { s.channelMgr = cm } + +// SetHookStore sets the hook store, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to expose hooks CRUD tools. +func (s *Server) SetHookStore(hs hooks.HookStore) { s.hookStore = hs } + +// SetHeartbeatStore sets the heartbeat store, used by the CRUD MCP server +// (see internal/mcp/crud_server.go) to expose heartbeat CRUD tools. +func (s *Server) SetHeartbeatStore(hb store.HeartbeatStore) { s.heartbeatStore = hb } + +// SetProviderStore sets the provider store, used by the CRUD MCP server +// (see internal/mcp/crud_server.go) to resolve provider names in heartbeat.set. +func (s *Server) SetProviderStore(ps store.ProviderStore) { s.providerStore = ps } + +// SetTenantStore sets the tenant store, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to resolve the optional "X-GoClaw-Tenant-Id" +// request header (UUID or slug) to a concrete tenant for every CRUD MCP call. +func (s *Server) SetTenantStore(ts store.TenantStore) { s.tenantStore = ts } + +// SetExecApprovalManager sets the exec approval manager, used by the CRUD MCP +// server (see internal/mcp/crud_server.go) to expose exec approval tools. +func (s *Server) SetExecApprovalManager(m *tools.ExecApprovalManager) { s.execApprovalMgr = m } + +// SetQuotaChecker sets the channel quota checker, used by the CRUD MCP server +// (see internal/mcp/crud_server.go) to expose the quota usage tool. +func (s *Server) SetQuotaChecker(qc *channels.QuotaChecker) { s.quotaChecker = qc } + +// SetSQLDB sets the raw *sql.DB handle, used by the CRUD MCP server (see +// internal/mcp/crud_server.go) to query today's quota trace summary. Distinct +// from SetDB's narrow PingContext-only interface used for health checks. +func (s *Server) SetSQLDB(db *sql.DB) { s.sqlDB = db } + +// SetLLMProviders sets the provider registry and background provider/model +// fallback used by the CRUD MCP server (see internal/mcp/crud_server.go) to +// expose goclaw_llm_complete, mirroring internal/gateway/methods/llm.go. +func (s *Server) SetLLMProviders(reg *providers.Registry, defaultProvider, defaultModel string) { + s.llmProviders = reg + s.llmDefaults = mcpbridge.LLMDefaults{Provider: defaultProvider, Model: defaultModel} +} + +// SetVoiceCache sets the shared TTS voice cache and per-tenant secrets store, +// used by the CRUD MCP server (see internal/mcp/crud_server.go) to expose +// goclaw_voices_{list,refresh}, mirroring internal/http/voices.go. +func (s *Server) SetVoiceCache(cache *audio.VoiceCache, secretStore store.ConfigSecretsStore) { + s.voiceCache = cache + s.voiceSecretsStore = secretStore +} + // SetWorkstationsHandler sets the workstations CRUD handler (Standard edition only). func (s *Server) SetWorkstationsHandler(h *httpapi.WorkstationsHandler) { s.handlers = append(s.handlers, h) diff --git a/internal/hooks/config.go b/internal/hooks/config.go index 0d1eaa3e..e55b947b 100644 --- a/internal/hooks/config.go +++ b/internal/hooks/config.go @@ -35,6 +35,7 @@ var knownEvents = map[HookEvent]struct{}{ EventStop: {}, EventSubagentStart: {}, EventSubagentStop: {}, + EventPostModelResponse: {}, } // Validate checks a HookConfig for semantic correctness and fills in defaults. diff --git a/internal/hooks/dispatcher.go b/internal/hooks/dispatcher.go index 5a2f0ba2..6fe6ca21 100644 --- a/internal/hooks/dispatcher.go +++ b/internal/hooks/dispatcher.go @@ -209,7 +209,7 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi slog.Warn("hooks.dispatch.decision", "hook_id", cfg.ID, "decision", "block", "reason", "circuit breaker open") d.writeExec(ctx, cfg, evMut, DecisionBlock, 0, "circuit breaker open", "") - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: "circuit breaker open"}, nil } pf := d.prefilter(cfg, evMut) if pf.errored { @@ -222,7 +222,7 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi slog.Warn("hooks.dispatch.decision", "hook_id", cfg.ID, "decision", "block", "reason", blockMsg) d.writeExec(ctx, cfg, evMut, DecisionBlock, 0, blockMsg, "") - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: blockMsg}, nil } if !pf.match { slog.Debug("hooks.dispatch.decision", @@ -263,21 +263,21 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi switch dec { case DecisionBlock: d.cb.record(ctx, cfg.ID, d.now(), d.store) - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: scriptRes.Reason}, nil case DecisionTimeout: d.cb.record(ctx, cfg.ID, d.now(), d.store) if cfg.OnTimeout == DecisionBlock { - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: "hook timeout"}, nil } // OnTimeout=allow: degrade gracefully but keep scanning. case DecisionError: // Unexpected error in a blocking chain → fail-closed. - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: "hook execution error: " + errMsg}, nil } if chainCtx.Err() != nil { // Chain wall-time budget exhausted (H3): fail-closed. - return FireResult{Decision: DecisionBlock}, nil + return FireResult{Decision: DecisionBlock, DecisionReason: "hook chain timeout"}, nil } } diff --git a/internal/hooks/handlers/script_runtime.go b/internal/hooks/handlers/script_runtime.go index 69313a55..0d9becf5 100644 --- a/internal/hooks/handlers/script_runtime.go +++ b/internal/hooks/handlers/script_runtime.go @@ -48,6 +48,24 @@ func bindEvent(rt *goja.Runtime, ev hooks.Event) error { "depth": ev.Depth, "eventId": ev.EventID, } + + // PostModelResponse-specific fields + if ev.HookEvent == hooks.EventPostModelResponse { + viewable["modelResponse"] = ev.ModelResponse + viewable["thinking"] = ev.Thinking + toolCalls := make([]map[string]any, len(ev.ToolCalls)) + for i, tc := range ev.ToolCalls { + toolCalls[i] = map[string]any{ + "id": tc.ID, + "name": tc.Name, + "arguments": tc.Arguments, + "metadata": tc.Metadata, + "parseError": tc.ParseError, + } + } + viewable["toolCalls"] = toolCalls + } + b, err := json.Marshal(viewable) if err != nil { return fmt.Errorf("bindEvent marshal: %w", err) diff --git a/internal/hooks/types.go b/internal/hooks/types.go index eed3ee51..2dd55d20 100644 --- a/internal/hooks/types.go +++ b/internal/hooks/types.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -33,6 +34,9 @@ const ( EventSubagentStart HookEvent = "subagent_start" // EventSubagentStop fires when a sub-agent finishes. EventSubagentStop HookEvent = "subagent_stop" + // EventPostModelResponse fires after the model generates its final response + // (no tool calls) but BEFORE it's delivered to the user. BLOCKING. + EventPostModelResponse HookEvent = "post_model_response" ) // IsBlocking returns true when the event requires a synchronous allow/block @@ -40,7 +44,7 @@ const ( // timeout yield Decision=block. func (e HookEvent) IsBlocking() bool { switch e { - case EventUserPromptSubmit, EventPreToolUse, EventSubagentStart: + case EventUserPromptSubmit, EventPreToolUse, EventSubagentStart, EventPostModelResponse: return true default: return false @@ -144,11 +148,13 @@ func (d Decision) IsBlock() bool { // UpdatedRawInput points to a string only when a builtin hook mutated // rawInput. Callers replace state.Input.Message with the dereferenced value. // -// For non-builtin scripts returning updatedInput the dispatcher strips the -// mutation + logs a WARN; Updated* stay nil (defense-in-depth against a -// tenant-authored script escalating its capability tier). +// DecisionReason carries a human-readable explanation when Decision is +// DecisionBlock (or DecisionAsk/DecisionDefer treated as block). This is +// injected as a user message to trigger a retry iteration. +// type FireResult struct { Decision Decision + DecisionReason string UpdatedToolInput map[string]any UpdatedRawInput *string } @@ -225,4 +231,9 @@ type Event struct { Depth int // HookEvent is the lifecycle event type. HookEvent HookEvent + + // PostModelResponse fields (populated when HookEvent == EventPostModelResponse). + ModelResponse string // the generated response content + Thinking string // reasoning content (if any) + ToolCalls []providers.ToolCall // empty for final response, populated if tool calls present } diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go index e7e3512c..b4d8dc99 100644 --- a/internal/http/skills_versions.go +++ b/internal/http/skills_versions.go @@ -1,6 +1,7 @@ package http import ( + "errors" "log/slog" "net/http" "os" @@ -8,7 +9,6 @@ import ( "sort" "strconv" "strings" - "time" "github.com/nextlevelbuilder/goclaw/internal/skills" @@ -298,7 +298,7 @@ func (h *SkillsHandler) handleWriteFile(w http.ResponseWriter, r *http.Request) return } - filePath, slug, currentVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), id) + _, _, _, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return @@ -318,109 +318,25 @@ func (h *SkillsHandler) handleWriteFile(w http.ResponseWriter, r *http.Request) } } - slugDir := store.SkillSlugDir(filePath) - if slugDir == "" { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) - return - } - currentDir := filepath.Join(slugDir, strconv.Itoa(currentVersion)) - if info, err := os.Stat(currentDir); err != nil || !info.IsDir() { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) - return - } - - cleanRelPath := filepath.Clean(relPath) - // Validate the path against the CURRENT version directory before staging - // a copy — cheaper failure path and keeps the escape/symlink checks close - // to the original request path. - checkPath := filepath.Join(currentDir, cleanRelPath) - if !strings.HasPrefix(checkPath, currentDir+string(filepath.Separator)) { - slog.Warn("security.skill_files_escape", "resolved", checkPath, "root", currentDir) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) - return - } - if fi, err := os.Lstat(checkPath); err == nil { - if fi.Mode()&os.ModeSymlink != 0 { - slog.Warn("security.skill_files_symlink", "path", checkPath) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) - return - } - if fi.IsDir() { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) - return - } - } - if skills.IsSystemArtifact(filepath.Base(cleanRelPath)) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) - return - } - - // Create a new immutable version: lock the next version number, stage a - // copy of the current version directory, write the edited file into the - // staged copy, then atomically rename it into place and repoint the - // skill's DB row — same convention as skill_manage's patch action and - // applySkillSuggestionPatch. - newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug) + path, newVersion, err := skills.WriteVersionedFile(r.Context(), h.skills, h.tenantSkillsDir(r), id, relPath, body.Content) if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - defer commitLock() //nolint:errcheck - - destDir := filepath.Join(h.tenantSkillsDir(r), slug, strconv.Itoa(newVersion)) - tmpDir := destDir + ".tmp-" + uuid.NewString() - if err := copyDir(currentDir, tmpDir); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - removeDestOnError := true - defer func() { - _ = os.RemoveAll(tmpDir) - if removeDestOnError { - _ = os.RemoveAll(destDir) + switch { + case errors.Is(err, skills.ErrSkillFileNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) + case errors.Is(err, skills.ErrSkillIsSystem): + writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot edit a system skill"}) + case errors.Is(err, skills.ErrSkillInvalidPath): + slog.Warn("security.skill_files_escape", "path", relPath, "skill_id", id.String()) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) } - }() - - absPath := filepath.Join(tmpDir, cleanRelPath) - if !strings.HasPrefix(absPath, tmpDir+string(filepath.Separator)) { - slog.Warn("security.skill_files_escape", "resolved", absPath, "root", tmpDir) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) - return - } - if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if err := os.WriteFile(absPath, []byte(body.Content), 0o644); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if err := os.Rename(tmpDir, destDir); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } - hash, size, err := hashSkillDir(destDir) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if err := h.skills.UpdateSkill(r.Context(), id, map[string]any{ - "version": newVersion, - "file_path": destDir, - "file_size": size, - "file_hash": &hash, - "updated_at": time.Now(), - }); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - removeDestOnError = false - - h.skills.BumpVersion() h.emitCacheInvalidate(bus.CacheKindSkills, id.String(), uuid.Nil) emitAudit(h.msgBus, r, "skill.file_updated", "skill", id.String()) - writeJSON(w, http.StatusOK, map[string]any{"ok": "true", "path": relPath, "version": newVersion}) + writeJSON(w, http.StatusOK, map[string]any{"ok": "true", "path": path, "version": newVersion}) } func readableSkillRoots(versionDir, slug string, isSystem bool, bundledDir string) []string { diff --git a/internal/mcp/crud_agent_links.go b/internal/mcp/crud_agent_links.go new file mode 100644 index 00000000..f00c8dcc --- /dev/null +++ b/internal/mcp/crud_agent_links.go @@ -0,0 +1,164 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerAgentLinkCRUDTools registers the goclaw_agent_links_* MCP tools +// backed by store.AgentLinkStore. +func registerAgentLinkCRUDTools(srv *mcpserver.MCPServer, links store.AgentLinkStore) { + srv.AddTool(mcpgo.NewTool("goclaw_agent_links_list", + mcpgo.WithDescription("List inter-agent delegation links for an agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID to list links for.")), + mcpgo.WithString("direction", mcpgo.Enum("from", "to", "all"), mcpgo.Description("Direction to list: \"from\" (default), \"to\", or \"all\".")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentLinksList(links)) + + srv.AddTool(mcpgo.NewTool("goclaw_agent_links_create", + mcpgo.WithDescription("Create a new inter-agent delegation link."), + mcpgo.WithString("source_agent", mcpgo.Required(), mcpgo.Description("Source agent UUID.")), + mcpgo.WithString("target_agent", mcpgo.Required(), mcpgo.Description("Target agent UUID.")), + mcpgo.WithString("direction", mcpgo.Enum("outbound", "inbound", "bidirectional"), mcpgo.Description("Link direction; defaults to \"outbound\".")), + mcpgo.WithString("description", mcpgo.Description("Human-readable description of the link.")), + mcpgo.WithNumber("max_concurrent", mcpgo.Description("Maximum concurrent delegated tasks.")), + ), handleAgentLinksCreate(links)) + + srv.AddTool(mcpgo.NewTool("goclaw_agent_links_update", + mcpgo.WithDescription("Apply a partial update to an agent link."), + mcpgo.WithString("link_id", mcpgo.Required(), mcpgo.Description("Link UUID.")), + mcpgo.WithString("direction", mcpgo.Enum("outbound", "inbound", "bidirectional"), mcpgo.Description("New direction.")), + mcpgo.WithString("description", mcpgo.Description("New description.")), + mcpgo.WithNumber("max_concurrent", mcpgo.Description("New max concurrent delegated tasks.")), + mcpgo.WithString("status", mcpgo.Enum("active", "disabled"), mcpgo.Description("New status.")), + ), handleAgentLinksUpdate(links)) + + srv.AddTool(mcpgo.NewTool("goclaw_agent_links_delete", + mcpgo.WithDescription("Delete an agent link."), + mcpgo.WithString("link_id", mcpgo.Required(), mcpgo.Description("Link UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleAgentLinksDelete(links)) +} + +func handleAgentLinksList(links store.AgentLinkStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentIDStr, err := req.RequireString("agent_id") + if err != nil { + return toolError("agent_links.list", err) + } + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + return toolError("agent_links.list", fmt.Errorf("invalid agent_id: %w", err)) + } + + direction := req.GetString("direction", "from") + var list []store.AgentLinkData + switch direction { + case "to": + list, err = links.ListLinksTo(ctx, agentID) + case "all": + var fromList, toList []store.AgentLinkData + fromList, err = links.ListLinksFrom(ctx, agentID) + if err == nil { + toList, err = links.ListLinksTo(ctx, agentID) + } + list = append(fromList, toList...) //nolint:gocritic // append(dst, src...) is intentional aggregation + default: + list, err = links.ListLinksFrom(ctx, agentID) + } + if err != nil { + return toolError("agent_links.list", err) + } + return jsonToolResult(map[string]any{"links": list, "count": len(list)}) + } +} + +func handleAgentLinksCreate(links store.AgentLinkStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + sourceStr, err := req.RequireString("source_agent") + if err != nil { + return toolError("agent_links.create", err) + } + targetStr, err := req.RequireString("target_agent") + if err != nil { + return toolError("agent_links.create", err) + } + sourceID, err := uuid.Parse(sourceStr) + if err != nil { + return toolError("agent_links.create", fmt.Errorf("invalid source_agent: %w", err)) + } + targetID, err := uuid.Parse(targetStr) + if err != nil { + return toolError("agent_links.create", fmt.Errorf("invalid target_agent: %w", err)) + } + + link := &store.AgentLinkData{ + BaseModel: store.BaseModel{ID: store.GenNewID()}, + SourceAgentID: sourceID, + TargetAgentID: targetID, + Direction: req.GetString("direction", store.LinkDirectionOutbound), + Description: req.GetString("description", ""), + MaxConcurrent: int(req.GetFloat("max_concurrent", 0)), + Status: store.LinkStatusActive, + } + if err := links.CreateLink(ctx, link); err != nil { + return toolError("agent_links.create", err) + } + return jsonToolResult(map[string]any{"link": link}) + } +} + +func handleAgentLinksUpdate(links store.AgentLinkStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + linkIDStr, err := req.RequireString("link_id") + if err != nil { + return toolError("agent_links.update", err) + } + linkID, err := uuid.Parse(linkIDStr) + if err != nil { + return toolError("agent_links.update", fmt.Errorf("invalid link_id: %w", err)) + } + + updates := map[string]any{} + args := req.GetArguments() + for _, key := range []string{"direction", "description", "status"} { + if v, ok := args[key]; ok { + updates[key] = v + } + } + if v, ok := args["max_concurrent"]; ok { + updates["max_concurrent"] = v + } + if len(updates) == 0 { + return mcpgo.NewToolResultError("agent_links.update: no fields to update"), nil + } + if err := links.UpdateLink(ctx, linkID, updates); err != nil { + return toolError("agent_links.update", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleAgentLinksDelete(links store.AgentLinkStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + linkIDStr, err := req.RequireString("link_id") + if err != nil { + return toolError("agent_links.delete", err) + } + linkID, err := uuid.Parse(linkIDStr) + if err != nil { + return toolError("agent_links.delete", fmt.Errorf("invalid link_id: %w", err)) + } + if err := links.DeleteLink(ctx, linkID); err != nil { + return toolError("agent_links.delete", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} diff --git a/internal/mcp/crud_agents.go b/internal/mcp/crud_agents.go new file mode 100644 index 00000000..51b5a399 --- /dev/null +++ b/internal/mcp/crud_agents.go @@ -0,0 +1,448 @@ +package mcp + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// AgentRuntimeLookup resolves a live agent's running state without the mcp +// package depending on internal/agent directly — internal/agent already +// imports internal/mcp (loop_mcp_user.go), so importing agent.Router here +// would create an import cycle. Callers (internal/gateway.Server) close over +// their *agent.Router to satisfy this. +type AgentRuntimeLookup func(ctx context.Context, agentID string) (id string, isRunning bool, err error) + +// allowedAgentContextFiles mirrors internal/gateway/methods/agents_files.go's +// allowedAgentFiles list (TOOLS.md intentionally excluded, not applicable via +// this surface). Duplicated here rather than imported since crud_*.go is a +// standalone MCP surface that does not depend on internal/gateway/methods. +var allowedAgentContextFiles = []string{ + bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.IdentityFile, + bootstrap.UserFile, bootstrap.UserPredefinedFile, bootstrap.CapabilitiesFile, + bootstrap.BootstrapFile, bootstrap.MemoryJSONFile, + bootstrap.HeartbeatFile, +} + +// registerAgentCRUDTools registers the goclaw_agents_* MCP tools backed by store.AgentStore. +func registerAgentCRUDTools(srv *mcpserver.MCPServer, agents store.AgentStore) { + srv.AddTool(mcpgo.NewTool("goclaw_agents_list", + mcpgo.WithDescription("List goclaw agents, optionally scoped to a specific owner."), + mcpgo.WithString("owner_id", mcpgo.Description("Filter by owner ID; empty lists all agents visible to the caller.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentsList(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_get", + mcpgo.WithDescription("Get a single goclaw agent by UUID or agent_key."), + mcpgo.WithString("id", mcpgo.Description("Agent UUID.")), + mcpgo.WithString("agent_key", mcpgo.Description("Agent key/slug, used when id is not known.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentsGet(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_create", + mcpgo.WithDescription("Create a new goclaw agent."), + mcpgo.WithString("agent_key", mcpgo.Required(), mcpgo.Description("Unique agent key/slug (e.g. \"support-bot\").")), + mcpgo.WithString("display_name", mcpgo.Description("Human-readable agent name; defaults to agent_key.")), + mcpgo.WithString("owner_id", mcpgo.Description("Owner user ID; defaults to \"system\".")), + mcpgo.WithString("provider", mcpgo.Description("LLM provider name (e.g. \"anthropic\").")), + mcpgo.WithString("model", mcpgo.Description("LLM model name.")), + mcpgo.WithString("workspace", mcpgo.Description("Workspace directory path for this agent.")), + mcpgo.WithString("agent_type", mcpgo.Description("\"open\" or \"predefined\"; defaults to \"predefined\".")), + ), handleAgentsCreate(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_update", + mcpgo.WithDescription("Apply a partial update to an existing agent."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithString("display_name", mcpgo.Description("New display name.")), + mcpgo.WithString("provider", mcpgo.Description("New LLM provider.")), + mcpgo.WithString("model", mcpgo.Description("New LLM model.")), + mcpgo.WithString("status", mcpgo.Description("New agent status.")), + mcpgo.WithNumber("context_window", mcpgo.Description("New context window size in tokens.")), + mcpgo.WithNumber("max_tool_iterations", mcpgo.Description("New max tool iterations per turn.")), + ), handleAgentsUpdate(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_delete", + mcpgo.WithDescription("Delete a goclaw agent by UUID."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleAgentsDelete(agents)) +} + +func handleAgentsList(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + ownerID := req.GetString("owner_id", "") + list, err := agents.List(ctx, ownerID) + if err != nil { + return toolError("agents.list", err) + } + return jsonToolResult(list) + } +} + +func handleAgentsGet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr := req.GetString("id", "") + agentKey := req.GetString("agent_key", "") + switch { + case idStr != "": + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("agents.get", fmt.Errorf("invalid id: %w", err)) + } + agent, err := agents.GetByID(ctx, id) + if err != nil { + return toolError("agents.get", err) + } + return jsonToolResult(agent) + case agentKey != "": + agent, err := agents.GetByKey(ctx, agentKey) + if err != nil { + return toolError("agents.get", err) + } + return jsonToolResult(agent) + default: + return mcpgo.NewToolResultError("agents.get: one of id or agent_key is required"), nil + } + } +} + +func handleAgentsCreate(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentKey, err := req.RequireString("agent_key") + if err != nil { + return toolError("agents.create", err) + } + + ownerID := req.GetString("owner_id", "system") + agentType := req.GetString("agent_type", store.AgentTypePredefined) + displayName := req.GetString("display_name", agentKey) + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + + data := &store.AgentData{ + BaseModel: store.BaseModel{ID: store.GenNewID()}, + TenantID: tenantID, + AgentKey: agentKey, + DisplayName: displayName, + OwnerID: ownerID, + Provider: req.GetString("provider", ""), + Model: req.GetString("model", ""), + Workspace: req.GetString("workspace", ""), + AgentType: agentType, + Status: "active", + } + + if err := agents.Create(ctx, data); err != nil { + return toolError("agents.create", err) + } + return jsonToolResult(data) + } +} + +func handleAgentsUpdate(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("agents.update", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("agents.update", fmt.Errorf("invalid id: %w", err)) + } + + updates := map[string]any{} + args := req.GetArguments() + for _, key := range []string{"display_name", "provider", "model", "status"} { + if v, ok := args[key]; ok { + updates[key] = v + } + } + if v, ok := args["context_window"]; ok { + updates["context_window"] = v + } + if v, ok := args["max_tool_iterations"]; ok { + updates["max_tool_iterations"] = v + } + if len(updates) == 0 { + return mcpgo.NewToolResultError("agents.update: no fields to update"), nil + } + + if err := agents.Update(ctx, id, updates); err != nil { + return toolError("agents.update", err) + } + agent, err := agents.GetByID(ctx, id) + if err != nil { + return toolError("agents.update", err) + } + return jsonToolResult(agent) + } +} + +func handleAgentsDelete(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("agents.delete", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("agents.delete", fmt.Errorf("invalid id: %w", err)) + } + if err := agents.Delete(ctx, id); err != nil { + return toolError("agents.delete", err) + } + return jsonToolResult(map[string]bool{"deleted": true}) + } +} + +// registerAgentRuntimeCRUDTools registers the goclaw_agent_{get,wait,identity_get} +// and goclaw_agents_files_{list,get,set} MCP tools. goclaw_agent_{get,wait} +// need the live agent runtime (for running-state) in addition to +// store.AgentStore (for context files/identity), unlike the plain CRUD tools +// above. +func registerAgentRuntimeCRUDTools(srv *mcpserver.MCPServer, agents store.AgentStore, lookup AgentRuntimeLookup) { + srv.AddTool(mcpgo.NewTool("goclaw_agent_get", + mcpgo.WithDescription("Return the running state for a single goclaw agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key/slug (or \"default\").")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentRuntimeGet(lookup)) + + srv.AddTool(mcpgo.NewTool("goclaw_agent_wait", + mcpgo.WithDescription("Wait for (or report the current status of) a goclaw agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key/slug (or \"default\").")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentWait(lookup)) + + srv.AddTool(mcpgo.NewTool("goclaw_agent_identity_get", + mcpgo.WithDescription("Return identity metadata (name, emoji, avatar, description) for an agent."), + mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug.")), + mcpgo.WithString("session_key", mcpgo.Description("Session key to extract the agent ID from, when agent_id is not known.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentIdentityGet(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_files_list", + mcpgo.WithDescription("List the well-known context files for an agent."), + mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug (or \"default\").")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentsFilesList(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_files_get", + mcpgo.WithDescription("Read a single well-known context file for an agent."), + mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug (or \"default\").")), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("File name (e.g. \"SOUL.md\").")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAgentsFilesGet(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_agents_files_set", + mcpgo.WithDescription("Write a single well-known context file for an agent."), + mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug (or \"default\").")), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("File name (e.g. \"SOUL.md\").")), + mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("New file content.")), + mcpgo.WithBoolean("propagate", mcpgo.Description("Also push this change to all existing per-user instances of the file (default false).")), + ), handleAgentsFilesSet(agents)) +} + +func isAllowedAgentContextFile(name string) bool { + return slices.Contains(allowedAgentContextFiles, name) +} + +func handleAgentRuntimeGet(lookup AgentRuntimeLookup) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "default") + id, isRunning, err := lookup(ctx, agentID) + if err != nil { + return toolError("agent.get", err) + } + return jsonToolResult(map[string]any{"id": id, "isRunning": isRunning}) + } +} + +func handleAgentWait(lookup AgentRuntimeLookup) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "default") + id, isRunning, err := lookup(ctx, agentID) + if err != nil { + return toolError("agent.wait", err) + } + status := "idle" + if isRunning { + status = "running" + } + return jsonToolResult(map[string]any{"id": id, "status": status}) + } +} + +// parseIdentityContent parses IDENTITY.md content string and extracts Key: Value fields. +// Mirrors internal/gateway/methods/agents_identity.go's unexported helper of +// the same name — duplicated for the same reason as allowedAgentContextFiles. +func parseIdentityContent(content string) map[string]string { + result := make(map[string]string) + for line := range strings.SplitSeq(content, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || line == "" { + continue + } + if idx := strings.Index(line, ":"); idx > 0 { + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + if val != "" { + result[key] = val + } + } + } + return result +} + +func handleAgentIdentityGet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "") + if agentID == "" { + if sessionKey := req.GetString("session_key", ""); sessionKey != "" { + parts := strings.SplitN(sessionKey, ":", 3) + if len(parts) >= 2 { + agentID = parts[1] + } + } + if agentID == "" { + agentID = "default" + } + } + + result := map[string]any{"agentId": agentID} + ag, err := agents.GetByKey(ctx, agentID) + if err != nil { + return jsonToolResult(result) + } + result["name"] = ag.DisplayName + + dbFiles, _ := agents.GetAgentContextFiles(ctx, ag.ID) + for _, f := range dbFiles { + if f.FileName != bootstrap.IdentityFile { + continue + } + identity := parseIdentityContent(f.Content) + if identity["Name"] != "" { + result["name"] = identity["Name"] + } + if identity["Emoji"] != "" { + result["emoji"] = identity["Emoji"] + } + if identity["Avatar"] != "" { + result["avatar"] = identity["Avatar"] + } + if identity["Description"] != "" { + result["description"] = identity["Description"] + } + break + } + return jsonToolResult(result) + } +} + +func handleAgentsFilesList(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "default") + ag, err := agents.GetByKey(ctx, agentID) + if err != nil { + return toolError("agents.files.list", err) + } + dbFiles, err := agents.GetAgentContextFiles(ctx, ag.ID) + if err != nil { + return toolError("agents.files.list", err) + } + dbMap := make(map[string]store.AgentContextFileData, len(dbFiles)) + for _, f := range dbFiles { + dbMap[f.FileName] = f + } + files := make([]map[string]any, 0, len(allowedAgentContextFiles)) + for _, name := range allowedAgentContextFiles { + if f, ok := dbMap[name]; ok { + files = append(files, map[string]any{"name": name, "missing": false, "size": len(f.Content)}) + } else { + files = append(files, map[string]any{"name": name, "missing": true}) + } + } + return jsonToolResult(map[string]any{"agentId": agentID, "files": files}) + } +} + +func handleAgentsFilesGet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "default") + name, err := req.RequireString("name") + if err != nil { + return toolError("agents.files.get", err) + } + if !isAllowedAgentContextFile(name) { + return mcpgo.NewToolResultError("agents.files.get: file not allowed: " + name), nil + } + ag, err := agents.GetByKey(ctx, agentID) + if err != nil { + return toolError("agents.files.get", err) + } + dbFiles, err := agents.GetAgentContextFiles(ctx, ag.ID) + if err != nil { + return toolError("agents.files.get", err) + } + for _, f := range dbFiles { + if f.FileName == name { + return jsonToolResult(map[string]any{ + "agentId": agentID, + "file": map[string]any{"name": name, "missing": false, "size": len(f.Content), "content": f.Content}, + }) + } + } + return jsonToolResult(map[string]any{ + "agentId": agentID, + "file": map[string]any{"name": name, "missing": true}, + }) + } +} + +func handleAgentsFilesSet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID := req.GetString("agent_id", "default") + name, err := req.RequireString("name") + if err != nil { + return toolError("agents.files.set", err) + } + if !isAllowedAgentContextFile(name) { + return mcpgo.NewToolResultError("agents.files.set: file not allowed: " + name), nil + } + content, err := req.RequireString("content") + if err != nil { + return toolError("agents.files.set", err) + } + ag, err := agents.GetByKey(ctx, agentID) + if err != nil { + return toolError("agents.files.set", err) + } + if err := agents.SetAgentContextFile(ctx, ag.ID, name, content); err != nil { + return toolError("agents.files.set", err) + } + propagated := 0 + if req.GetBool("propagate", false) { + n, err := agents.PropagateContextFile(ctx, ag.ID, name) + if err == nil { + propagated = n + } + } + return jsonToolResult(map[string]any{ + "agentId": agentID, + "file": map[string]any{"name": name, "missing": false, "size": len(content), "content": content}, + "propagated": propagated, + }) + } +} diff --git a/internal/mcp/crud_agents_test.go b/internal/mcp/crud_agents_test.go new file mode 100644 index 00000000..c95aeb20 --- /dev/null +++ b/internal/mcp/crud_agents_test.go @@ -0,0 +1,131 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestAgentsCRUD_CreateGetUpdateDelete(t *testing.T) { + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerAgentCRUDTools(srv, agents) + + created := callTool(t, srv, "goclaw_agents_create", map[string]any{ + "agent_key": "support-bot", + }) + require.False(t, toolIsError(created), "create should succeed: %s", toolResultText(created)) + assert.Contains(t, toolResultText(created), "support-bot") + + // Happy path: get by agent_key. + got := callTool(t, srv, "goclaw_agents_get", map[string]any{"agent_key": "support-bot"}) + require.False(t, toolIsError(got)) + assert.Contains(t, toolResultText(got), "support-bot") + + // Error path: get with neither id nor agent_key. + missingArgs := callTool(t, srv, "goclaw_agents_get", map[string]any{}) + assert.True(t, toolIsError(missingArgs)) + + // Error path: get a nonexistent agent by id. + notFound := callTool(t, srv, "goclaw_agents_get", map[string]any{"id": uuid.New().String()}) + assert.True(t, toolIsError(notFound)) + + var id uuid.UUID + for k := range agents.byID { + id = k + } + + updated := callTool(t, srv, "goclaw_agents_update", map[string]any{ + "id": id.String(), "display_name": "Support Bot v2", + }) + require.False(t, toolIsError(updated), toolResultText(updated)) + assert.Contains(t, toolResultText(updated), "Support Bot v2") + + // Error path: update with no fields to update. + noFields := callTool(t, srv, "goclaw_agents_update", map[string]any{"id": id.String()}) + assert.True(t, toolIsError(noFields)) + + deleted := callTool(t, srv, "goclaw_agents_delete", map[string]any{"id": id.String()}) + require.False(t, toolIsError(deleted)) + assert.Contains(t, toolResultText(deleted), "true") + + // Error path: deleting again fails (already gone). + deleteAgain := callTool(t, srv, "goclaw_agents_delete", map[string]any{"id": id.String()}) + assert.True(t, toolIsError(deleteAgain)) +} + +func TestAgentsCRUD_List(t *testing.T) { + agents := newFakeAgentStore() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: uuid.New()}, AgentKey: "a", OwnerID: "u1"}) + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: uuid.New()}, AgentKey: "b", OwnerID: "u2"}) + srv := newTestMCPServer() + registerAgentCRUDTools(srv, agents) + + result := callTool(t, srv, "goclaw_agents_list", map[string]any{"owner_id": "u1"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), `"a"`) + assert.NotContains(t, toolResultText(result), `"b"`) +} + +func TestAgentRuntimeCRUD_GetAndWait(t *testing.T) { + srv := newTestMCPServer() + agents := newFakeAgentStore() + + var called []string + lookup := AgentRuntimeLookup(func(_ context.Context, agentID string) (string, bool, error) { + called = append(called, agentID) + return "resolved-" + agentID, agentID == "running-agent", nil + }) + registerAgentRuntimeCRUDTools(srv, agents, lookup) + + idle := callTool(t, srv, "goclaw_agent_get", map[string]any{"agent_id": "idle-agent"}) + require.False(t, toolIsError(idle)) + assert.Contains(t, toolResultText(idle), `"isRunning":false`) + + running := callTool(t, srv, "goclaw_agent_wait", map[string]any{"agent_id": "running-agent"}) + require.False(t, toolIsError(running)) + assert.Contains(t, toolResultText(running), `"status":"running"`) + + assert.Equal(t, []string{"idle-agent", "running-agent"}, called) +} + +func TestAgentIdentityGet_DefaultsWhenAgentMissing(t *testing.T) { + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerAgentRuntimeCRUDTools(srv, agents, func(_ context.Context, agentID string) (string, bool, error) { return agentID, false, nil }) + + result := callTool(t, srv, "goclaw_agent_identity_get", map[string]any{"agent_id": "unknown"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), `"agentId":"unknown"`) +} + +func TestAgentsFilesGet_RejectsDisallowedFileName(t *testing.T) { + agents := newFakeAgentStore() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: uuid.New()}, AgentKey: "default"}) + srv := newTestMCPServer() + registerAgentRuntimeCRUDTools(srv, agents, func(_ context.Context, agentID string) (string, bool, error) { return agentID, false, nil }) + + result := callTool(t, srv, "goclaw_agents_files_get", map[string]any{"agent_id": "default", "name": "TOOLS.md"}) + assert.True(t, toolIsError(result), "TOOLS.md is intentionally excluded from allowedAgentContextFiles") +} + +func TestAgentsFilesSetAndGet_RoundTrip(t *testing.T) { + agents := newFakeAgentStore() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: uuid.New()}, AgentKey: "default"}) + srv := newTestMCPServer() + registerAgentRuntimeCRUDTools(srv, agents, func(_ context.Context, agentID string) (string, bool, error) { return agentID, false, nil }) + + setResult := callTool(t, srv, "goclaw_agents_files_set", map[string]any{ + "agent_id": "default", "name": "SOUL.md", "content": "be helpful", + }) + require.False(t, toolIsError(setResult)) + + getResult := callTool(t, srv, "goclaw_agents_files_get", map[string]any{"agent_id": "default", "name": "SOUL.md"}) + require.False(t, toolIsError(getResult)) + assert.Contains(t, toolResultText(getResult), "be helpful") +} diff --git a/internal/mcp/crud_api_keys.go b/internal/mcp/crud_api_keys.go new file mode 100644 index 00000000..16538553 --- /dev/null +++ b/internal/mcp/crud_api_keys.go @@ -0,0 +1,124 @@ +package mcp + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const apiKeyRawBytes = 32 + +// registerAPIKeyCRUDTools registers the goclaw_api_keys_* MCP tools backed by +// store.APIKeyStore. +func registerAPIKeyCRUDTools(srv *mcpserver.MCPServer, apiKeys store.APIKeyStore) { + srv.AddTool(mcpgo.NewTool("goclaw_api_keys_list", + mcpgo.WithDescription("List API keys visible to the caller."), + mcpgo.WithString("owner_id", mcpgo.Description("Filter by owner user ID; empty lists all keys.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleAPIKeysList(apiKeys)) + + srv.AddTool(mcpgo.NewTool("goclaw_api_keys_create", + mcpgo.WithDescription("Create a new API key. The raw key value is only returned once."), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Descriptive name for the key.")), + mcpgo.WithArray("scopes", mcpgo.Required(), mcpgo.Description("Scopes granted to this key (e.g. [\"operator.admin\"]).")), + mcpgo.WithNumber("expires_in", mcpgo.Description("Expiry in seconds from now; omit for a non-expiring key.")), + mcpgo.WithString("owner_id", mcpgo.Description("User ID this key is bound to.")), + ), handleAPIKeysCreate(apiKeys)) + + srv.AddTool(mcpgo.NewTool("goclaw_api_keys_revoke", + mcpgo.WithDescription("Revoke an API key."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("API key UUID.")), + mcpgo.WithString("owner_id", mcpgo.Description("If set, also enforces owner_id match before revoking.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleAPIKeysRevoke(apiKeys)) +} + +func handleAPIKeysList(apiKeys store.APIKeyStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + ownerID := req.GetString("owner_id", "") + list, err := apiKeys.List(ctx, ownerID) + if err != nil { + return toolError("api_keys.list", err) + } + return jsonToolResult(list) + } +} + +func handleAPIKeysCreate(apiKeys store.APIKeyStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return toolError("api_keys.create", err) + } + scopesRaw, err := req.RequireStringSlice("scopes") + if err != nil { + return toolError("api_keys.create", err) + } + + rawKey := make([]byte, apiKeyRawBytes) + if _, err := rand.Read(rawKey); err != nil { + return toolError("api_keys.create", fmt.Errorf("generate key: %w", err)) + } + rawKeyHex := hex.EncodeToString(rawKey) + hash := sha256.Sum256([]byte(rawKeyHex)) + keyHash := hex.EncodeToString(hash[:]) + + var expiresAt *time.Time + if expiresIn := req.GetFloat("expires_in", 0); expiresIn > 0 { + t := time.Now().Add(time.Duration(expiresIn) * time.Second) + expiresAt = &t + } + + data := &store.APIKeyData{ + ID: store.GenNewID(), + Name: name, + Prefix: rawKeyHex[:apiKeyPrefixLen], + KeyHash: keyHash, + Scopes: scopesRaw, + OwnerID: req.GetString("owner_id", ""), + ExpiresAt: expiresAt, + } + if err := apiKeys.Create(ctx, data); err != nil { + return toolError("api_keys.create", err) + } + return jsonToolResult(map[string]any{ + "id": data.ID, + "name": data.Name, + "prefix": data.Prefix, + "key": rawKeyHex, + "scopes": data.Scopes, + "expires_at": data.ExpiresAt, + "created_at": data.CreatedAt, + }) + } +} + +const apiKeyPrefixLen = 8 + +func handleAPIKeysRevoke(apiKeys store.APIKeyStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("api_keys.revoke", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("api_keys.revoke", fmt.Errorf("invalid id: %w", err)) + } + ownerID := req.GetString("owner_id", "") + if err := apiKeys.Revoke(ctx, id, ownerID); err != nil { + return toolError("api_keys.revoke", err) + } + return jsonToolResult(map[string]string{"status": "revoked"}) + } +} diff --git a/internal/mcp/crud_bitrix.go b/internal/mcp/crud_bitrix.go new file mode 100644 index 00000000..ec74e8ae --- /dev/null +++ b/internal/mcp/crud_bitrix.go @@ -0,0 +1,145 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// bitrixPortalView is the credential-masked API view of a Bitrix24 portal. +type bitrixPortalView struct { + Name string `json:"name"` + Domain string `json:"domain"` + Installed bool `json:"installed"` +} + +// registerBitrixCRUDTools registers the goclaw_bitrix_portals_* MCP tools +// backed by store.BitrixPortalStore. Requires a tenant-scoped context +// (store.WithTenantID) or master scope, per store.BitrixPortalStore's own +// contract — this server does not additionally gate access. +func registerBitrixCRUDTools(srv *mcpserver.MCPServer, portals store.BitrixPortalStore) { + srv.AddTool(mcpgo.NewTool("goclaw_bitrix_portals_list", + mcpgo.WithDescription("List Bitrix24 portals for the caller's tenant (credentials masked)."), + mcpgo.WithString("tenant_id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleBitrixPortalsList(portals)) + + srv.AddTool(mcpgo.NewTool("goclaw_bitrix_portals_create", + mcpgo.WithDescription("Provision a new Bitrix24 portal."), + mcpgo.WithString("tenant_id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Portal name (unique per tenant).")), + mcpgo.WithString("domain", mcpgo.Required(), mcpgo.Description("Bitrix24 portal domain.")), + mcpgo.WithString("client_id", mcpgo.Required(), mcpgo.Description("Bitrix24 OAuth app client ID.")), + mcpgo.WithString("client_secret", mcpgo.Required(), mcpgo.Description("Bitrix24 OAuth app client secret.")), + ), handleBitrixPortalsCreate(portals)) + + srv.AddTool(mcpgo.NewTool("goclaw_bitrix_portals_delete", + mcpgo.WithDescription("Delete a Bitrix24 portal."), + mcpgo.WithString("tenant_id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Portal name.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleBitrixPortalsDelete(portals)) +} + +func handleBitrixPortalsList(portals store.BitrixPortalStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + tenantID, err := parseTenantID(req) + if err != nil { + return toolError("bitrix_portals.list", err) + } + list, err := portals.ListByTenant(ctx, tenantID) + if err != nil { + return toolError("bitrix_portals.list", err) + } + views := make([]bitrixPortalView, 0, len(list)) + for _, p := range list { + installed := false + if len(p.State) > 0 { + var state store.BitrixPortalState + if err := json.Unmarshal(p.State, &state); err == nil { + installed = state.AccessToken != "" + } + } + views = append(views, bitrixPortalView{Name: p.Name, Domain: p.Domain, Installed: installed}) + } + return jsonToolResult(map[string]any{"portals": views}) + } +} + +func handleBitrixPortalsCreate(portals store.BitrixPortalStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + tenantID, err := parseTenantID(req) + if err != nil { + return toolError("bitrix_portals.create", err) + } + name, err := req.RequireString("name") + if err != nil { + return toolError("bitrix_portals.create", err) + } + domain, err := req.RequireString("domain") + if err != nil { + return toolError("bitrix_portals.create", err) + } + clientID, err := req.RequireString("client_id") + if err != nil { + return toolError("bitrix_portals.create", err) + } + clientSecret, err := req.RequireString("client_secret") + if err != nil { + return toolError("bitrix_portals.create", err) + } + + creds, err := json.Marshal(store.BitrixPortalCredentials{ClientID: clientID, ClientSecret: clientSecret}) + if err != nil { + return toolError("bitrix_portals.create", fmt.Errorf("marshal credentials: %w", err)) + } + portal := &store.BitrixPortalData{ + BaseModel: store.BaseModel{ID: store.GenNewID()}, + TenantID: tenantID, + Name: name, + Domain: domain, + Credentials: creds, + } + if err := portals.Create(ctx, portal); err != nil { + return toolError("bitrix_portals.create", err) + } + return jsonToolResult(map[string]string{"name": name, "domain": domain}) + } +} + +func handleBitrixPortalsDelete(portals store.BitrixPortalStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + tenantID, err := parseTenantID(req) + if err != nil { + return toolError("bitrix_portals.delete", err) + } + name, err := req.RequireString("name") + if err != nil { + return toolError("bitrix_portals.delete", err) + } + if err := portals.Delete(ctx, tenantID, name); err != nil { + return toolError("bitrix_portals.delete", err) + } + return jsonToolResult(map[string]string{"status": "deleted"}) + } +} + +// parseTenantID reads and parses the required "tenant_id" argument. +func parseTenantID(req mcpgo.CallToolRequest) (uuid.UUID, error) { + tenantIDStr, err := req.RequireString("tenant_id") + if err != nil { + return uuid.Nil, err + } + tenantID, err := uuid.Parse(tenantIDStr) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid tenant_id: %w", err) + } + return tenantID, nil +} diff --git a/internal/mcp/crud_channels.go b/internal/mcp/crud_channels.go new file mode 100644 index 00000000..39017b5b --- /dev/null +++ b/internal/mcp/crud_channels.go @@ -0,0 +1,281 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerChannelsCRUDTools registers the goclaw_channels_* MCP tools backed +// by the runtime *channels.Manager. Mirrors internal/gateway/methods/channels.go. +func registerChannelsCRUDTools(srv *mcpserver.MCPServer, mgr *channels.Manager) { + srv.AddTool(mcpgo.NewTool("goclaw_channels_list", + mcpgo.WithDescription("List enabled goclaw channels."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChannelsList(mgr)) + + srv.AddTool(mcpgo.NewTool("goclaw_channels_status", + mcpgo.WithDescription("Return the connection status for all goclaw channels."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChannelsStatus(mgr)) + + srv.AddTool(mcpgo.NewTool("goclaw_channels_toggle", + mcpgo.WithDescription("Enable or disable a channel. NOTE: not yet implemented server-side — always returns a \"not implemented\" error (matches the WS twin, channels.toggle, which requires a channel restart not yet supported)."), + mcpgo.WithString("channel", mcpgo.Required(), mcpgo.Description("Channel name.")), + mcpgo.WithBoolean("enabled", mcpgo.Required(), mcpgo.Description("Desired enabled state.")), + ), handleChannelsToggle()) +} + +func handleChannelsList(mgr *channels.Manager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(map[string]any{"channels": mgr.GetEnabledChannels()}) + } +} + +func handleChannelsStatus(mgr *channels.Manager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(map[string]any{"channels": mgr.GetStatus()}) + } +} + +func handleChannelsToggle() mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultError("channels.toggle: not implemented"), nil + } +} + +// channelInstanceAllowed mirrors the HTTP/WS allowlist in +// internal/gateway/methods/channel_instances.go and internal/http/validate.go. +var channelInstanceAllowed = map[string]bool{ + "channel_type": true, "credentials": true, "agent_id": true, + "enabled": true, "group_policy": true, "allow_from": true, + "metadata": true, "webhook_secret": true, "config": true, + "display_name": true, +} + +// isValidChannelType mirrors internal/gateway/methods/channel_instances.go's +// unexported helper of the same name. Keep in sync with that switch and with +// CHANNEL_TYPES in ui/web/src/constants/channels.ts. +func isValidChannelType(ct string) bool { + switch ct { + case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24": + return true + } + return false +} + +// maskChannelInstance mirrors internal/gateway/methods/channel_instances.go's +// unexported helper of the same name — never expose raw credentials via MCP. +func maskChannelInstance(inst store.ChannelInstanceData) map[string]any { + result := map[string]any{ + "id": inst.ID, "name": inst.Name, "display_name": inst.DisplayName, + "channel_type": inst.ChannelType, "agent_id": inst.AgentID, "config": inst.Config, + "enabled": inst.Enabled, "is_default": store.IsDefaultChannelInstance(inst.Name), + "has_credentials": len(inst.Credentials) > 0, "created_by": inst.CreatedBy, + "created_at": inst.CreatedAt, "updated_at": inst.UpdatedAt, + } + if len(inst.Credentials) > 0 { + var raw map[string]any + if json.Unmarshal(inst.Credentials, &raw) == nil { + masked := make(map[string]any, len(raw)) + for k := range raw { + masked[k] = "***" + } + result["credentials"] = masked + } else { + result["credentials"] = map[string]string{} + } + } else { + result["credentials"] = map[string]string{} + } + return result +} + +// registerChannelInstancesCRUDTools registers the goclaw_channel_instances_* +// MCP tools backed by store.ChannelInstanceStore. +func registerChannelInstancesCRUDTools(srv *mcpserver.MCPServer, insts store.ChannelInstanceStore, agents store.AgentStore) { + srv.AddTool(mcpgo.NewTool("goclaw_channel_instances_list", + mcpgo.WithDescription("List all channel instances (credentials masked)."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChannelInstancesList(insts)) + + srv.AddTool(mcpgo.NewTool("goclaw_channel_instances_get", + mcpgo.WithDescription("Get a single channel instance by UUID (credentials masked)."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Instance UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChannelInstancesGet(insts)) + + srv.AddTool(mcpgo.NewTool("goclaw_channel_instances_create", + mcpgo.WithDescription("Create a new channel instance."), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Instance name.")), + mcpgo.WithString("display_name", mcpgo.Description("Human-readable display name.")), + mcpgo.WithString("channel_type", mcpgo.Required(), mcpgo.Description("Channel type (telegram, discord, slack, whatsapp, zalo_oa, zalo_personal, feishu, facebook, pancake, bitrix24).")), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Owning agent key or UUID.")), + mcpgo.WithObject("credentials", mcpgo.Description("Channel credentials object.")), + mcpgo.WithObject("config", mcpgo.Description("Channel config object.")), + mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state; defaults to true.")), + ), handleChannelInstancesCreate(insts, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_channel_instances_update", + mcpgo.WithDescription("Apply a partial update to a channel instance."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Instance UUID.")), + mcpgo.WithObject("updates", mcpgo.Required(), mcpgo.Description("Column→value patch (allowlisted keys only).")), + ), handleChannelInstancesUpdate(insts)) + + srv.AddTool(mcpgo.NewTool("goclaw_channel_instances_delete", + mcpgo.WithDescription("Delete a channel instance. Refuses to delete default (seeded) instances."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Instance UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleChannelInstancesDelete(insts)) +} + +func handleChannelInstancesList(insts store.ChannelInstanceStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + list, err := insts.ListAll(ctx) + if err != nil { + return toolError("channels.instances.list", err) + } + result := make([]map[string]any, 0, len(list)) + for _, inst := range list { + result = append(result, maskChannelInstance(inst)) + } + return jsonToolResult(map[string]any{"instances": result}) + } +} + +func handleChannelInstancesGet(insts store.ChannelInstanceStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("channels.instances.get", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("channels.instances.get", fmt.Errorf("invalid id: %w", err)) + } + inst, err := insts.Get(ctx, id) + if err != nil { + return toolError("channels.instances.get", err) + } + return jsonToolResult(maskChannelInstance(*inst)) + } +} + +func handleChannelInstancesCreate(insts store.ChannelInstanceStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return toolError("channels.instances.create", err) + } + channelType, err := req.RequireString("channel_type") + if err != nil { + return toolError("channels.instances.create", err) + } + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("channels.instances.create", err) + } + if !isValidChannelType(channelType) { + return mcpgo.NewToolResultError("channels.instances.create: invalid channel_type"), nil + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("channels.instances.create", fmt.Errorf("invalid agent_id: %w", err)) + } + + args := req.GetArguments() + var credentials, cfgRaw json.RawMessage + if v, ok := args["credentials"]; ok { + credentials, _ = json.Marshal(v) + } + if v, ok := args["config"]; ok { + cfgRaw, _ = json.Marshal(v) + } + + inst := &store.ChannelInstanceData{ + Name: name, DisplayName: req.GetString("display_name", ""), ChannelType: channelType, + AgentID: agentID, Credentials: credentials, + Config: config.NormalizeChannelInstanceConfigRaw(channelType, cfgRaw), + Enabled: req.GetBool("enabled", true), + } + if err := insts.Create(ctx, inst); err != nil { + return toolError("channels.instances.create", err) + } + return jsonToolResult(maskChannelInstance(*inst)) + } +} + +func handleChannelInstancesUpdate(insts store.ChannelInstanceStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("channels.instances.update", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("channels.instances.update", fmt.Errorf("invalid id: %w", err)) + } + args := req.GetArguments() + raw, _ := args["updates"].(map[string]any) + if len(raw) == 0 { + return mcpgo.NewToolResultError("channels.instances.update: updates is required"), nil + } + + updates := make(map[string]any, len(raw)) + for k, v := range raw { + if channelInstanceAllowed[k] { + updates[k] = v + } else { + slog.Warn("security.filtered_unknown_field", "field", k, "handler", "mcp.channels.instances.update") + } + } + if value, ok := updates["config"]; ok { + channelType, _ := updates["channel_type"].(string) + if channelType == "" { + if inst, err := insts.Get(ctx, id); err == nil { + channelType = inst.ChannelType + } + } + updates["config"] = config.NormalizeChannelInstanceConfigValue(channelType, value) + } + + if err := insts.Update(ctx, id, updates); err != nil { + return toolError("channels.instances.update", err) + } + return jsonToolResult(map[string]string{"status": "updated"}) + } +} + +func handleChannelInstancesDelete(insts store.ChannelInstanceStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + idStr, err := req.RequireString("id") + if err != nil { + return toolError("channels.instances.delete", err) + } + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("channels.instances.delete", fmt.Errorf("invalid id: %w", err)) + } + inst, err := insts.Get(ctx, id) + if err != nil { + return toolError("channels.instances.delete", err) + } + if store.IsDefaultChannelInstance(inst.Name) { + return mcpgo.NewToolResultError("channels.instances.delete: cannot delete a default instance"), nil + } + if err := insts.Delete(ctx, id); err != nil { + return toolError("channels.instances.delete", err) + } + return jsonToolResult(map[string]string{"status": "deleted"}) + } +} diff --git a/internal/mcp/crud_channels_test.go b/internal/mcp/crud_channels_test.go new file mode 100644 index 00000000..83d4069b --- /dev/null +++ b/internal/mcp/crud_channels_test.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestChannelsList_And_Status(t *testing.T) { + mgr := channels.NewManager(bus.New()) + srv := newTestMCPServer() + registerChannelsCRUDTools(srv, mgr) + + list := callTool(t, srv, "goclaw_channels_list", map[string]any{}) + require.False(t, toolIsError(list)) + + status := callTool(t, srv, "goclaw_channels_status", map[string]any{}) + require.False(t, toolIsError(status)) +} + +// TestChannelsToggle_AlwaysNotImplemented guards the documented stub +// behavior against accidental regression (or accidental silent +// implementation without updating the tool description). +func TestChannelsToggle_AlwaysNotImplemented(t *testing.T) { + mgr := channels.NewManager(bus.New()) + srv := newTestMCPServer() + registerChannelsCRUDTools(srv, mgr) + + result := callTool(t, srv, "goclaw_channels_toggle", map[string]any{"channel": "telegram", "enabled": true}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "not implemented") +} + +func TestChannelInstances_CreateGetUpdateDelete(t *testing.T) { + insts := newFakeChannelInstanceStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "support"}) + srv := newTestMCPServer() + registerChannelInstancesCRUDTools(srv, insts, agents) + + created := callTool(t, srv, "goclaw_channel_instances_create", map[string]any{ + "name": "tg-1", "channel_type": "telegram", "agent_id": "support", + }) + require.False(t, toolIsError(created), toolResultText(created)) + assert.Contains(t, toolResultText(created), "tg-1") + // Credentials must never appear in cleartext. + assert.NotContains(t, toolResultText(created), "***\":\"realvalue") + + var id uuid.UUID + for k := range insts.byID { + id = k + } + + got := callTool(t, srv, "goclaw_channel_instances_get", map[string]any{"id": id.String()}) + require.False(t, toolIsError(got)) + + invalidType := callTool(t, srv, "goclaw_channel_instances_create", map[string]any{ + "name": "bad", "channel_type": "not-a-real-type", "agent_id": "support", + }) + assert.True(t, toolIsError(invalidType)) + + updated := callTool(t, srv, "goclaw_channel_instances_update", map[string]any{ + "id": id.String(), "updates": map[string]any{"enabled": false}, + }) + require.False(t, toolIsError(updated)) + assert.False(t, insts.byID[id].Enabled) + + deleted := callTool(t, srv, "goclaw_channel_instances_delete", map[string]any{"id": id.String()}) + require.False(t, toolIsError(deleted)) +} + +func TestChannelInstancesDelete_RefusesDefaultInstance(t *testing.T) { + insts := newFakeChannelInstanceStore() + agents := newFakeAgentStore() + id := uuid.New() + insts.byID[id] = &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: id}, Name: "telegram"} // "telegram" is a default/seeded name + srv := newTestMCPServer() + registerChannelInstancesCRUDTools(srv, insts, agents) + + result := callTool(t, srv, "goclaw_channel_instances_delete", map[string]any{"id": id.String()}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "cannot delete a default instance") +} diff --git a/internal/mcp/crud_chat.go b/internal/mcp/crud_chat.go new file mode 100644 index 00000000..fdaed678 --- /dev/null +++ b/internal/mcp/crud_chat.go @@ -0,0 +1,177 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerChatCRUDTools registers the goclaw_chat_* MCP tools backed by the +// live agent runtime (via ChatRunner) and store.SessionStore. +func registerChatCRUDTools(srv *mcpserver.MCPServer, runner ChatRunner, sessions store.SessionStore) { + srv.AddTool(mcpgo.NewTool("goclaw_chat_send", + mcpgo.WithDescription("Send a chat message to a goclaw agent and receive the assistant's reply. Always synchronous — the underlying run's incremental events (if any) are not forwarded, only the final result."), + mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("The user message to send.")), + mcpgo.WithString("agent_id", mcpgo.Description("Agent key/slug (defaults to \"default\", or is inferred from session_key when provided).")), + mcpgo.WithString("session_key", mcpgo.Description("Existing session key to resume; a new one is created when omitted.")), + ), handleChatSend(runner)) + + srv.AddTool(mcpgo.NewTool("goclaw_chat_history", + mcpgo.WithDescription("Fetch the message history for a goclaw chat session."), + mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChatHistory(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_chat_inject", + mcpgo.WithDescription("Inject a message into a goclaw session's transcript without triggering an agent run."), + mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("Message text to inject.")), + mcpgo.WithString("label", mcpgo.Description("Optional label prefix (e.g. \"note\"), truncated to 100 chars.")), + ), handleChatInject(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_chat_abort", + mcpgo.WithDescription("Cancel a running goclaw agent invocation for a session or a specific run ID."), + mcpgo.WithString("run_id", mcpgo.Description("Specific run ID to abort.")), + mcpgo.WithString("session_key", mcpgo.Description("Session key whose active run(s) should be aborted.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleChatAbort(runner)) + + srv.AddTool(mcpgo.NewTool("goclaw_chat_session_status", + mcpgo.WithDescription("Return the running state and current activity (phase, tool, iteration) for a goclaw chat session."), + mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChatSessionStatus(runner)) +} + +const maxInjectLabelLen = 100 + +func handleChatSend(runner ChatRunner) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if runner == nil { + return mcpgo.NewToolResultError("chat.send: chat runtime not available"), nil + } + message, err := req.RequireString("message") + if err != nil { + return toolError("chat.send", err) + } + agentID := req.GetString("agent_id", "") + sessionKey := req.GetString("session_key", "") + result, err := runner.Send(ctx, agentID, sessionKey, message, nil) + if err != nil { + return toolError("chat.send", err) + } + return jsonToolResult(result) + } +} + +func handleChatHistory(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + sessionKey, err := req.RequireString("session_key") + if err != nil { + return toolError("chat.history", err) + } + history := sessions.GetHistory(ctx, sessionKey) + return jsonToolResult(map[string]any{"messages": history}) + } +} + +func handleChatInject(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + sessionKey, err := req.RequireString("session_key") + if err != nil { + return toolError("chat.inject", err) + } + message, err := req.RequireString("message") + if err != nil { + return toolError("chat.inject", err) + } + label := req.GetString("label", "") + if len(label) > maxInjectLabelLen { + label = label[:maxInjectLabelLen] + } + text := message + if label != "" { + text = "[" + label + "]\n\n" + message + } + sessions.AddMessage(ctx, sessionKey, providers.Message{Role: "assistant", Content: text}) + return jsonToolResult(map[string]any{"ok": true}) + } +} + +func handleChatAbort(runner ChatRunner) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if runner == nil { + return mcpgo.NewToolResultError("chat.abort: chat runtime not available"), nil + } + runID := req.GetString("run_id", "") + sessionKey := req.GetString("session_key", "") + if runID == "" && sessionKey == "" { + return mcpgo.NewToolResultError("chat.abort: one of run_id or session_key is required"), nil + } + result, err := runner.Abort(ctx, runID, sessionKey) + if err != nil { + return toolError("chat.abort", err) + } + return jsonToolResult(result) + } +} + +func handleChatSessionStatus(runner ChatRunner) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if runner == nil { + return mcpgo.NewToolResultError("chat.session.status: chat runtime not available"), nil + } + sessionKey, err := req.RequireString("session_key") + if err != nil { + return toolError("chat.session.status", err) + } + result, err := runner.SessionStatus(ctx, sessionKey) + if err != nil { + return toolError("chat.session.status", err) + } + return jsonToolResult(result) + } +} + +// registerChatBehaviorCRUDTool registers goclaw_chat_behavior_preview, backed +// by the same channels.ResolveChatBehavior/PreviewResolvedChatBehavior logic +// used by the WS chat_behavior.preview method +// (internal/gateway/methods/chat_behavior.go). The WS method additionally +// requires master scope + owner role, enforced via the WS client's resolved +// role/tenant; this MCP surface has no such per-caller identity (the bearer +// token is the sole boundary), matching the rest of this CRUD MCP server. +func registerChatBehaviorCRUDTool(srv *mcpserver.MCPServer, cfg *config.Config, channelMgr *channels.Manager) { + srv.AddTool(mcpgo.NewTool("goclaw_chat_behavior_preview", + mcpgo.WithDescription("Preview resolved channel delivery behavior (streaming/quick-ack/final-split) for a channel or an ad-hoc config."), + mcpgo.WithString("channel", mcpgo.Description("Channel instance name to resolve behavior for; empty uses the global default.")), + mcpgo.WithString("content", mcpgo.Description("Sample content to preview delivery for.")), + mcpgo.WithBoolean("is_streaming", mcpgo.Description("Whether the sample response is streaming.")), + mcpgo.WithBoolean("has_tool_calls", mcpgo.Description("Whether the sample response includes tool calls.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleChatBehaviorPreview(cfg, channelMgr)) +} + +func handleChatBehaviorPreview(cfg *config.Config, channelMgr *channels.Manager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + channel := req.GetString("channel", "") + + var resolved channels.ResolvedChatBehavior + if channelMgr != nil { + resolved = channelMgr.ResolveChatBehavior(channel, cfg.Gateway.ChatBehavior) + } else { + resolved = channels.ResolveChatBehavior(cfg.Gateway.ChatBehavior, nil) + } + preview := channels.PreviewResolvedChatBehavior(resolved, channels.ChatBehaviorPreviewOptions{ + Content: req.GetString("content", ""), + IsStreaming: req.GetBool("is_streaming", false), + HasToolCalls: req.GetBool("has_tool_calls", false), + }) + return jsonToolResult(preview) + } +} diff --git a/internal/mcp/crud_chat_test.go b/internal/mcp/crud_chat_test.go new file mode 100644 index 00000000..d42493d0 --- /dev/null +++ b/internal/mcp/crud_chat_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func TestChatSend_HappyPath(t *testing.T) { + runner := &fakeChatRunner{} + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerChatCRUDTools(srv, runner, sessions) + + result := callTool(t, srv, "goclaw_chat_send", map[string]any{"message": "hello"}) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Equal(t, "hello", runner.lastMessage) +} + +func TestChatSend_RunnerError(t *testing.T) { + runner := &fakeChatRunner{sendErr: assertErr("provider down")} + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerChatCRUDTools(srv, runner, sessions) + + result := callTool(t, srv, "goclaw_chat_send", map[string]any{"message": "hello"}) + assert.True(t, toolIsError(result)) +} + +func TestChatSend_NilRunner(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerChatCRUDTools(srv, nil, sessions) + + result := callTool(t, srv, "goclaw_chat_send", map[string]any{"message": "hello"}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "chat runtime not available") +} + +func TestChatHistory(t *testing.T) { + sessions := newFakeSessionStore() + sessions.history["sess-1"] = []providers.Message{{Role: "user", Content: "hi"}} + runner := &fakeChatRunner{} + srv := newTestMCPServer() + registerChatCRUDTools(srv, runner, sessions) + + result := callTool(t, srv, "goclaw_chat_history", map[string]any{"session_key": "sess-1"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "hi") +} + +func TestChatInject_AddsLabeledMessage(t *testing.T) { + sessions := newFakeSessionStore() + runner := &fakeChatRunner{} + srv := newTestMCPServer() + registerChatCRUDTools(srv, runner, sessions) + + result := callTool(t, srv, "goclaw_chat_inject", map[string]any{ + "session_key": "sess-1", "message": "note text", "label": "note", + }) + require.False(t, toolIsError(result)) + require.Len(t, sessions.history["sess-1"], 1) + assert.Contains(t, sessions.history["sess-1"][0].Content, "note text") +} + +func TestChatAbort_RequiresRunIDOrSessionKey(t *testing.T) { + runner := &fakeChatRunner{} + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerChatCRUDTools(srv, runner, sessions) + + result := callTool(t, srv, "goclaw_chat_abort", map[string]any{}) + assert.True(t, toolIsError(result)) +} + +func TestChatSessionStatus_NilRunner(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerChatCRUDTools(srv, nil, sessions) + + result := callTool(t, srv, "goclaw_chat_session_status", map[string]any{"session_key": "sess-1"}) + assert.True(t, toolIsError(result)) +} + +func TestChatBehaviorPreview_NoChannelManager(t *testing.T) { + cfg := &config.Config{} + srv := newTestMCPServer() + registerChatBehaviorCRUDTool(srv, cfg, nil) + + result := callTool(t, srv, "goclaw_chat_behavior_preview", map[string]any{"content": "hello"}) + require.False(t, toolIsError(result)) +} diff --git a/internal/mcp/crud_config.go b/internal/mcp/crud_config.go new file mode 100644 index 00000000..64f49a30 --- /dev/null +++ b/internal/mcp/crud_config.go @@ -0,0 +1,28 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// registerConfigCRUDTools registers the goclaw_config_get MCP tool. Config is +// read-only through this surface — mutating live gateway config from an MCP +// tool call is out of scope; use the existing config.patch WS method / HTTP +// admin API for writes, which enforce permission and validation rules this +// server does not duplicate. +func registerConfigCRUDTools(srv *mcpserver.MCPServer, cfg *config.Config) { + srv.AddTool(mcpgo.NewTool("goclaw_config_get", + mcpgo.WithDescription("Get the current gateway configuration, with all secrets masked."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleConfigGet(cfg)) +} + +func handleConfigGet(cfg *config.Config) mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(cfg.MaskedCopy()) + } +} diff --git a/internal/mcp/crud_config_permissions.go b/internal/mcp/crud_config_permissions.go new file mode 100644 index 00000000..c14c3b07 --- /dev/null +++ b/internal/mcp/crud_config_permissions.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerConfigPermissionCRUDTools registers the goclaw_config_permissions_* +// MCP tools backed by store.ConfigPermissionStore. +func registerConfigPermissionCRUDTools(srv *mcpserver.MCPServer, perms store.ConfigPermissionStore) { + srv.AddTool(mcpgo.NewTool("goclaw_config_permissions_list", + mcpgo.WithDescription("List config permissions for an agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithString("config_type", mcpgo.Description("Config type to filter by (\"file_writer\", \"heartbeat\", \"cron\", \"context_files\", or \"*\").")), + mcpgo.WithString("scope", mcpgo.Description("Scope to filter by; empty lists all scopes.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleConfigPermissionsList(perms)) + + srv.AddTool(mcpgo.NewTool("goclaw_config_permissions_check", + mcpgo.WithDescription("Check a config permission decision for an agent/scope/user."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithString("scope", mcpgo.Required(), mcpgo.Description("Permission scope.")), + mcpgo.WithString("config_type", mcpgo.Required(), mcpgo.Description("Config type.")), + mcpgo.WithString("user_id", mcpgo.Required(), mcpgo.Description("User ID to check.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleConfigPermissionsCheck(perms)) + + srv.AddTool(mcpgo.NewTool("goclaw_config_permissions_grant", + mcpgo.WithDescription("Grant a config permission."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithString("scope", mcpgo.Required(), mcpgo.Description("Permission scope.")), + mcpgo.WithString("config_type", mcpgo.Required(), mcpgo.Description("Config type.")), + mcpgo.WithString("user_id", mcpgo.Required(), mcpgo.Description("User ID to grant to.")), + mcpgo.WithString("permission", mcpgo.Required(), mcpgo.Enum("allow", "deny"), mcpgo.Description("\"allow\" or \"deny\".")), + mcpgo.WithString("granted_by", mcpgo.Description("User ID recorded as the granter.")), + ), handleConfigPermissionsGrant(perms)) + + srv.AddTool(mcpgo.NewTool("goclaw_config_permissions_revoke", + mcpgo.WithDescription("Revoke a config permission."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID.")), + mcpgo.WithString("scope", mcpgo.Required(), mcpgo.Description("Permission scope.")), + mcpgo.WithString("config_type", mcpgo.Required(), mcpgo.Description("Config type.")), + mcpgo.WithString("user_id", mcpgo.Required(), mcpgo.Description("User ID to revoke from.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleConfigPermissionsRevoke(perms)) +} + +func handleConfigPermissionsList(perms store.ConfigPermissionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentIDStr, err := req.RequireString("agent_id") + if err != nil { + return toolError("config_permissions.list", err) + } + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + return toolError("config_permissions.list", fmt.Errorf("invalid agent_id: %w", err)) + } + configType := req.GetString("config_type", "") + scope := req.GetString("scope", "") + list, err := perms.List(ctx, agentID, configType, scope) + if err != nil { + return toolError("config_permissions.list", err) + } + return jsonToolResult(map[string]any{"permissions": list}) + } +} + +func handleConfigPermissionsCheck(perms store.ConfigPermissionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentIDStr, err := req.RequireString("agent_id") + if err != nil { + return toolError("config_permissions.check", err) + } + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + return toolError("config_permissions.check", fmt.Errorf("invalid agent_id: %w", err)) + } + scope, err := req.RequireString("scope") + if err != nil { + return toolError("config_permissions.check", err) + } + configType, err := req.RequireString("config_type") + if err != nil { + return toolError("config_permissions.check", err) + } + userID, err := req.RequireString("user_id") + if err != nil { + return toolError("config_permissions.check", err) + } + decision, err := store.CheckConfigPermissionDecision(ctx, perms, agentID, scope, configType, userID) + if err != nil { + return toolError("config_permissions.check", err) + } + return jsonToolResult(map[string]any{"decision": decision}) + } +} + +func handleConfigPermissionsGrant(perms store.ConfigPermissionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentIDStr, err := req.RequireString("agent_id") + if err != nil { + return toolError("config_permissions.grant", err) + } + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + return toolError("config_permissions.grant", fmt.Errorf("invalid agent_id: %w", err)) + } + scope, err := req.RequireString("scope") + if err != nil { + return toolError("config_permissions.grant", err) + } + configType, err := req.RequireString("config_type") + if err != nil { + return toolError("config_permissions.grant", err) + } + userID, err := req.RequireString("user_id") + if err != nil { + return toolError("config_permissions.grant", err) + } + permission, err := req.RequireString("permission") + if err != nil { + return toolError("config_permissions.grant", err) + } + if !store.ValidConfigPermission(permission) { + return mcpgo.NewToolResultError("config_permissions.grant: permission must be \"allow\" or \"deny\""), nil + } + + perm := &store.ConfigPermission{ + ID: store.GenNewID(), + AgentID: agentID, + Scope: scope, + ConfigType: configType, + UserID: userID, + Permission: permission, + } + if grantedBy := req.GetString("granted_by", ""); grantedBy != "" { + perm.GrantedBy = &grantedBy + } + if err := perms.Grant(ctx, perm); err != nil { + return toolError("config_permissions.grant", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleConfigPermissionsRevoke(perms store.ConfigPermissionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentIDStr, err := req.RequireString("agent_id") + if err != nil { + return toolError("config_permissions.revoke", err) + } + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + return toolError("config_permissions.revoke", fmt.Errorf("invalid agent_id: %w", err)) + } + scope, err := req.RequireString("scope") + if err != nil { + return toolError("config_permissions.revoke", err) + } + configType, err := req.RequireString("config_type") + if err != nil { + return toolError("config_permissions.revoke", err) + } + userID, err := req.RequireString("user_id") + if err != nil { + return toolError("config_permissions.revoke", err) + } + if err := perms.Revoke(ctx, agentID, scope, configType, userID); err != nil { + return toolError("config_permissions.revoke", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} diff --git a/internal/mcp/crud_config_test.go b/internal/mcp/crud_config_test.go new file mode 100644 index 00000000..f32ce871 --- /dev/null +++ b/internal/mcp/crud_config_test.go @@ -0,0 +1,22 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestConfigGet_ReturnsMaskedCopy(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.Token = "super-secret-token" + srv := newTestMCPServer() + registerConfigCRUDTools(srv, cfg) + + result := callTool(t, srv, "goclaw_config_get", map[string]any{}) + require.False(t, toolIsError(result)) + // MaskedCopy must never leak the raw token verbatim. + assert.NotContains(t, toolResultText(result), "super-secret-token") +} diff --git a/internal/mcp/crud_cron.go b/internal/mcp/crud_cron.go new file mode 100644 index 00000000..7a7dc69b --- /dev/null +++ b/internal/mcp/crud_cron.go @@ -0,0 +1,269 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerCronCRUDTools registers the goclaw_cron_* MCP tools backed by store.CronStore. +func registerCronCRUDTools(srv *mcpserver.MCPServer, cron store.CronStore) { + srv.AddTool(mcpgo.NewTool("goclaw_cron_list", + mcpgo.WithDescription("List scheduled cron jobs."), + mcpgo.WithBoolean("include_disabled", mcpgo.Description("Include disabled jobs (default false).")), + mcpgo.WithString("agent_id", mcpgo.Description("Filter by agent ID.")), + mcpgo.WithString("user_id", mcpgo.Description("Filter by user ID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleCronList(cron)) + + // goclaw_cron_get is a goclaw-specific extra (not present in the + // reference tool set, which only exposes cron.status for scheduler-wide + // state) — kept for convenience since store.CronStore.GetJob supports it + // directly and it's useful for single-job lookups. + srv.AddTool(mcpgo.NewTool("goclaw_cron_get", + mcpgo.WithDescription("Get a single cron job by ID."), + mcpgo.WithString("job_id", mcpgo.Required(), mcpgo.Description("Cron job ID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleCronGet(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_create", + mcpgo.WithDescription("Create a message-delivery cron job (schedule kind \"at\", \"every\", or \"cron\")."), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Job name.")), + mcpgo.WithString("schedule_kind", mcpgo.Required(), mcpgo.Enum("at", "every", "cron"), mcpgo.Description("Schedule kind.")), + mcpgo.WithNumber("at_ms", mcpgo.Description("Unix ms timestamp; required when schedule_kind is \"at\".")), + mcpgo.WithNumber("every_ms", mcpgo.Description("Interval in ms; required when schedule_kind is \"every\".")), + mcpgo.WithString("expr", mcpgo.Description("Cron expression; required when schedule_kind is \"cron\".")), + mcpgo.WithString("tz", mcpgo.Description("IANA timezone for the schedule.")), + mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("Message text delivered when the job fires.")), + mcpgo.WithBoolean("deliver", mcpgo.Description("Whether to deliver the message to a channel (default false).")), + mcpgo.WithString("channel", mcpgo.Description("Delivery channel, when deliver is true.")), + mcpgo.WithString("to", mcpgo.Description("Delivery recipient/chat ID, when deliver is true.")), + mcpgo.WithString("agent_id", mcpgo.Description("Owning agent ID.")), + mcpgo.WithString("user_id", mcpgo.Description("Owning user ID.")), + ), handleCronCreate(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_update", + mcpgo.WithDescription("Apply a partial update to an existing cron job."), + mcpgo.WithString("job_id", mcpgo.Required(), mcpgo.Description("Cron job ID.")), + mcpgo.WithString("name", mcpgo.Description("New job name.")), + mcpgo.WithBoolean("enabled", mcpgo.Description("New enabled state.")), + mcpgo.WithString("message", mcpgo.Description("New message text.")), + mcpgo.WithString("schedule_kind", mcpgo.Enum("at", "every", "cron"), mcpgo.Description("New schedule kind (requires the matching at_ms/every_ms/expr field).")), + mcpgo.WithNumber("at_ms", mcpgo.Description("New unix ms timestamp for schedule_kind \"at\".")), + mcpgo.WithNumber("every_ms", mcpgo.Description("New interval in ms for schedule_kind \"every\".")), + mcpgo.WithString("expr", mcpgo.Description("New cron expression for schedule_kind \"cron\".")), + mcpgo.WithString("tz", mcpgo.Description("New IANA timezone.")), + mcpgo.WithBoolean("deliver", mcpgo.Description("New deliver flag.")), + mcpgo.WithString("deliver_channel", mcpgo.Description("New delivery channel.")), + mcpgo.WithString("deliver_to", mcpgo.Description("New delivery recipient/chat ID.")), + ), handleCronUpdate(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_delete", + mcpgo.WithDescription("Delete a cron job by ID."), + mcpgo.WithString("job_id", mcpgo.Required(), mcpgo.Description("Cron job ID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleCronDelete(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_toggle", + mcpgo.WithDescription("Enable or disable a cron job."), + mcpgo.WithString("job_id", mcpgo.Required(), mcpgo.Description("Cron job ID.")), + mcpgo.WithBoolean("enabled", mcpgo.Required(), mcpgo.Description("Desired enabled state.")), + ), handleCronToggle(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_run", + mcpgo.WithDescription("Trigger an immediate (background) run of a cron job."), + mcpgo.WithString("job_id", mcpgo.Required(), mcpgo.Description("Cron job ID.")), + mcpgo.WithString("mode", mcpgo.Enum("force", "due"), mcpgo.Description("\"force\" runs regardless of schedule; \"due\" (default) only runs if due.")), + ), handleCronRun(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_runs", + mcpgo.WithDescription("Return the run log entries for a cron job."), + mcpgo.WithString("job_id", mcpgo.Description("Cron job ID; empty returns entries across all jobs, if supported.")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return.")), + mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleCronRuns(cron)) + + srv.AddTool(mcpgo.NewTool("goclaw_cron_status", + mcpgo.WithDescription("Return the cron scheduler's overall status."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleCronStatus(cron)) +} + +func handleCronList(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + includeDisabled := req.GetBool("include_disabled", false) + agentID := req.GetString("agent_id", "") + userID := req.GetString("user_id", "") + list := cron.ListJobs(ctx, includeDisabled, agentID, userID) + return jsonToolResult(list) + } +} + +func handleCronGet(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID, err := req.RequireString("job_id") + if err != nil { + return toolError("cron.get", err) + } + job, ok := cron.GetJob(ctx, jobID) + if !ok { + return mcpgo.NewToolResultError("cron.get: job not found: " + jobID), nil + } + return jsonToolResult(job) + } +} + +func handleCronCreate(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return toolError("cron.create", err) + } + kind, err := req.RequireString("schedule_kind") + if err != nil { + return toolError("cron.create", err) + } + message, err := req.RequireString("message") + if err != nil { + return toolError("cron.create", err) + } + + schedule := store.CronSchedule{Kind: kind, TZ: req.GetString("tz", "")} + switch kind { + case "at": + ms := int64(req.GetFloat("at_ms", 0)) + schedule.AtMS = &ms + case "every": + ms := int64(req.GetFloat("every_ms", 0)) + schedule.EveryMS = &ms + case "cron": + schedule.Expr = req.GetString("expr", "") + } + + deliver := req.GetBool("deliver", false) + channel := req.GetString("channel", "") + to := req.GetString("to", "") + agentID := req.GetString("agent_id", "") + userID := req.GetString("user_id", "") + + job, err := cron.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID) + if err != nil { + return toolError("cron.create", err) + } + return jsonToolResult(job) + } +} + +func handleCronUpdate(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID, err := req.RequireString("job_id") + if err != nil { + return toolError("cron.update", err) + } + + args := req.GetArguments() + patch := store.CronJobPatch{Name: req.GetString("name", "")} + if v, ok := args["enabled"]; ok { + if b, ok := v.(bool); ok { + patch.Enabled = &b + } + } + if msg := req.GetString("message", ""); msg != "" { + patch.Message = msg + } + if kind := req.GetString("schedule_kind", ""); kind != "" { + schedule := store.CronSchedule{Kind: kind, TZ: req.GetString("tz", "")} + switch kind { + case "at": + ms := int64(req.GetFloat("at_ms", 0)) + schedule.AtMS = &ms + case "every": + ms := int64(req.GetFloat("every_ms", 0)) + schedule.EveryMS = &ms + case "cron": + schedule.Expr = req.GetString("expr", "") + } + patch.Schedule = &schedule + } + if v, ok := args["deliver"]; ok { + if b, ok := v.(bool); ok { + patch.Deliver = &b + } + } + if ch := req.GetString("deliver_channel", ""); ch != "" { + patch.DeliverChannel = &ch + } + if to := req.GetString("deliver_to", ""); to != "" { + patch.DeliverTo = &to + } + + job, err := cron.UpdateJob(ctx, jobID, patch) + if err != nil { + return toolError("cron.update", err) + } + return jsonToolResult(map[string]any{"job": job}) + } +} + +func handleCronDelete(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID, err := req.RequireString("job_id") + if err != nil { + return toolError("cron.delete", err) + } + if err := cron.RemoveJob(ctx, jobID); err != nil { + return toolError("cron.delete", err) + } + return jsonToolResult(map[string]bool{"deleted": true}) + } +} + +func handleCronToggle(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID, err := req.RequireString("job_id") + if err != nil { + return toolError("cron.toggle", err) + } + args := req.GetArguments() + enabled, _ := args["enabled"].(bool) + if err := cron.EnableJob(ctx, jobID, enabled); err != nil { + return toolError("cron.toggle", err) + } + return jsonToolResult(map[string]any{"jobId": jobID, "enabled": enabled}) + } +} + +func handleCronRun(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID, err := req.RequireString("job_id") + if err != nil { + return toolError("cron.run", err) + } + force := req.GetString("mode", "due") == "force" + ran, _, err := cron.RunJob(ctx, jobID, force) + if err != nil { + return toolError("cron.run", err) + } + return jsonToolResult(map[string]bool{"ok": true, "ran": ran}) + } +} + +func handleCronRuns(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + jobID := req.GetString("job_id", "") + limit := int(req.GetFloat("limit", 0)) + offset := int(req.GetFloat("offset", 0)) + entries, total := cron.GetRunLog(ctx, jobID, limit, offset) + return jsonToolResult(map[string]any{"entries": entries, "total": total}) + } +} + +func handleCronStatus(cron store.CronStore) mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(map[string]any{"status": cron.Status()}) + } +} diff --git a/internal/mcp/crud_cron_test.go b/internal/mcp/crud_cron_test.go new file mode 100644 index 00000000..8193071a --- /dev/null +++ b/internal/mcp/crud_cron_test.go @@ -0,0 +1,92 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCronCRUD_CreateGetUpdateToggleDelete(t *testing.T) { + cron := newFakeCronStore() + srv := newTestMCPServer() + registerCronCRUDTools(srv, cron) + + created := callTool(t, srv, "goclaw_cron_create", map[string]any{ + "name": "daily-report", + "schedule_kind": "every", + "every_ms": float64(60000), + "message": "run report", + }) + require.False(t, toolIsError(created), toolResultText(created)) + assert.Contains(t, toolResultText(created), "daily-report") + + var jobID string + for id := range cron.jobs { + jobID = id + } + + got := callTool(t, srv, "goclaw_cron_get", map[string]any{"job_id": jobID}) + require.False(t, toolIsError(got)) + assert.Contains(t, toolResultText(got), "daily-report") + + notFound := callTool(t, srv, "goclaw_cron_get", map[string]any{"job_id": "missing"}) + assert.True(t, toolIsError(notFound)) + + updated := callTool(t, srv, "goclaw_cron_update", map[string]any{"job_id": jobID, "name": "renamed"}) + require.False(t, toolIsError(updated)) + assert.Equal(t, "renamed", cron.jobs[jobID].Name) + + toggled := callTool(t, srv, "goclaw_cron_toggle", map[string]any{"job_id": jobID, "enabled": false}) + require.False(t, toolIsError(toggled)) + assert.False(t, cron.jobs[jobID].Enabled) + + deleted := callTool(t, srv, "goclaw_cron_delete", map[string]any{"job_id": jobID}) + require.False(t, toolIsError(deleted)) + + deleteAgain := callTool(t, srv, "goclaw_cron_delete", map[string]any{"job_id": jobID}) + assert.True(t, toolIsError(deleteAgain)) +} + +func TestCronRun_JobNotFound(t *testing.T) { + cron := newFakeCronStore() + srv := newTestMCPServer() + registerCronCRUDTools(srv, cron) + + result := callTool(t, srv, "goclaw_cron_run", map[string]any{"job_id": "missing", "mode": "force"}) + assert.True(t, toolIsError(result)) +} + +func TestCronList_ExcludesDisabledByDefault(t *testing.T) { + cron := newFakeCronStore() + srv := newTestMCPServer() + registerCronCRUDTools(srv, cron) + + created := callTool(t, srv, "goclaw_cron_create", map[string]any{ + "name": "job1", "schedule_kind": "every", "every_ms": float64(1000), "message": "hi", + }) + require.False(t, toolIsError(created)) + var jobID string + for id := range cron.jobs { + jobID = id + } + callTool(t, srv, "goclaw_cron_toggle", map[string]any{"job_id": jobID, "enabled": false}) + + list := callTool(t, srv, "goclaw_cron_list", map[string]any{}) + require.False(t, toolIsError(list)) + assert.NotContains(t, toolResultText(list), "job1") + + listAll := callTool(t, srv, "goclaw_cron_list", map[string]any{"include_disabled": true}) + require.False(t, toolIsError(listAll)) + assert.Contains(t, toolResultText(listAll), "job1") +} + +func TestCronStatus(t *testing.T) { + cron := newFakeCronStore() + srv := newTestMCPServer() + registerCronCRUDTools(srv, cron) + + result := callTool(t, srv, "goclaw_cron_status", map[string]any{}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "status") +} diff --git a/internal/mcp/crud_exec_approval.go b/internal/mcp/crud_exec_approval.go new file mode 100644 index 00000000..0c6d8151 --- /dev/null +++ b/internal/mcp/crud_exec_approval.go @@ -0,0 +1,80 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// registerExecApprovalCRUDTools registers the goclaw_exec_approval_* MCP +// tools backed by *tools.ExecApprovalManager. Mirrors +// internal/gateway/methods/exec_approval.go. +func registerExecApprovalCRUDTools(srv *mcpserver.MCPServer, manager *tools.ExecApprovalManager) { + srv.AddTool(mcpgo.NewTool("goclaw_exec_approval_list", + mcpgo.WithDescription("List pending shell exec approvals."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleExecApprovalList(manager)) + + srv.AddTool(mcpgo.NewTool("goclaw_exec_approval_approve", + mcpgo.WithDescription("Approve a pending shell exec approval."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Approval ID.")), + mcpgo.WithBoolean("always", mcpgo.Description("true = allow-always, false (default) = allow-once.")), + ), handleExecApprovalApprove(manager)) + + srv.AddTool(mcpgo.NewTool("goclaw_exec_approval_deny", + mcpgo.WithDescription("Deny a pending shell exec approval."), + mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Approval ID.")), + ), handleExecApprovalDeny(manager)) +} + +func handleExecApprovalList(manager *tools.ExecApprovalManager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + pending := manager.ListPending() + type pendingInfo struct { + ID string `json:"id"` + Command string `json:"command"` + AgentID string `json:"agentId"` + CreatedAt int64 `json:"createdAt"` + } + items := make([]pendingInfo, 0, len(pending)) + for _, pa := range pending { + items = append(items, pendingInfo{ + ID: pa.ID, Command: pa.Command, AgentID: pa.AgentID, CreatedAt: pa.CreatedAt.UnixMilli(), + }) + } + return jsonToolResult(map[string]any{"pending": items}) + } +} + +func handleExecApprovalApprove(manager *tools.ExecApprovalManager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return toolError("exec.approval.approve", err) + } + decision := tools.ApprovalAllowOnce + if req.GetBool("always", false) { + decision = tools.ApprovalAllowAlways + } + if err := manager.Resolve(id, decision); err != nil { + return toolError("exec.approval.approve", err) + } + return jsonToolResult(map[string]any{"resolved": true, "decision": string(decision)}) + } +} + +func handleExecApprovalDeny(manager *tools.ExecApprovalManager) mcpserver.ToolHandlerFunc { + return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return toolError("exec.approval.deny", err) + } + if err := manager.Resolve(id, tools.ApprovalDeny); err != nil { + return toolError("exec.approval.deny", err) + } + return jsonToolResult(map[string]any{"resolved": true, "decision": "deny"}) + } +} diff --git a/internal/mcp/crud_fakes_test.go b/internal/mcp/crud_fakes_test.go new file mode 100644 index 00000000..a37fb981 --- /dev/null +++ b/internal/mcp/crud_fakes_test.go @@ -0,0 +1,860 @@ +package mcp + +// crud_fakes_test.go holds hand-written in-memory fake stores shared across +// the crud_*_test.go files. Following the convention already used in +// internal/gateway/methods/*_test.go (see hooks_test.go, sessions_test.go): +// each fake embeds the real store interface (nil) so any method the tests +// don't exercise panics loudly instead of silently returning zero values — +// intentional, since a panic means a test started depending on untested +// behavior and should implement it explicitly. + +import ( + "context" + "database/sql" + "errors" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ---- fakeAgentStore ---- + +type fakeAgentStore struct { + store.AgentStore + byID map[uuid.UUID]*store.AgentData + byKey map[string]*store.AgentData + contextFiles map[uuid.UUID][]store.AgentContextFileData + createErr error + updateErr error + deleteErr error + lastUpdate map[string]any + lastUpdateID uuid.UUID + propagateN int +} + +func newFakeAgentStore() *fakeAgentStore { + return &fakeAgentStore{ + byID: map[uuid.UUID]*store.AgentData{}, + byKey: map[string]*store.AgentData{}, + contextFiles: map[uuid.UUID][]store.AgentContextFileData{}, + } +} + +func (f *fakeAgentStore) add(ag *store.AgentData) { + f.byID[ag.ID] = ag + f.byKey[ag.AgentKey] = ag +} + +func (f *fakeAgentStore) Create(_ context.Context, agent *store.AgentData) error { + if f.createErr != nil { + return f.createErr + } + f.add(agent) + return nil +} + +func (f *fakeAgentStore) GetByID(_ context.Context, id uuid.UUID) (*store.AgentData, error) { + if ag, ok := f.byID[id]; ok { + return ag, nil + } + return nil, errors.New("agent not found") +} + +func (f *fakeAgentStore) GetByKey(_ context.Context, key string) (*store.AgentData, error) { + if ag, ok := f.byKey[key]; ok { + return ag, nil + } + return nil, errors.New("agent not found") +} + +func (f *fakeAgentStore) List(_ context.Context, ownerID string) ([]store.AgentData, error) { + var out []store.AgentData + for _, ag := range f.byID { + if ownerID != "" && ag.OwnerID != ownerID { + continue + } + out = append(out, *ag) + } + return out, nil +} + +func (f *fakeAgentStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + if f.updateErr != nil { + return f.updateErr + } + ag, ok := f.byID[id] + if !ok { + return errors.New("agent not found") + } + f.lastUpdate = updates + f.lastUpdateID = id + if v, ok := updates["display_name"].(string); ok { + ag.DisplayName = v + } + if v, ok := updates["status"].(string); ok { + ag.Status = v + } + return nil +} + +func (f *fakeAgentStore) Delete(_ context.Context, id uuid.UUID) error { + if f.deleteErr != nil { + return f.deleteErr + } + ag, ok := f.byID[id] + if !ok { + return errors.New("agent not found") + } + delete(f.byID, id) + delete(f.byKey, ag.AgentKey) + return nil +} + +func (f *fakeAgentStore) GetAgentContextFiles(_ context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) { + return f.contextFiles[agentID], nil +} + +func (f *fakeAgentStore) SetAgentContextFile(_ context.Context, agentID uuid.UUID, fileName, content string) error { + files := f.contextFiles[agentID] + for i, existing := range files { + if existing.FileName == fileName { + files[i].Content = content + f.contextFiles[agentID] = files + return nil + } + } + f.contextFiles[agentID] = append(files, store.AgentContextFileData{FileName: fileName, Content: content}) + return nil +} + +func (f *fakeAgentStore) PropagateContextFile(_ context.Context, _ uuid.UUID, _ string) (int, error) { + return f.propagateN, nil +} + +// ---- fakeSessionStore ---- + +type fakeSessionStore struct { + store.SessionStore + sessions map[string]*store.SessionData + deleted []string + resetKeys []string + labels map[string]string + metadata map[string]map[string]string + history map[string][]providers.Message + deleteErr error +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{ + sessions: map[string]*store.SessionData{}, + labels: map[string]string{}, + metadata: map[string]map[string]string{}, + history: map[string][]providers.Message{}, + } +} + +func (f *fakeSessionStore) add(key string, sess *store.SessionData) { + f.sessions[key] = sess + f.history[key] = sess.Messages +} + +func (f *fakeSessionStore) Get(_ context.Context, key string) *store.SessionData { + return f.sessions[key] +} + +func (f *fakeSessionStore) List(_ context.Context, _ string) []store.SessionInfo { + var out []store.SessionInfo + for k := range f.sessions { + out = append(out, store.SessionInfo{Key: k}) + } + return out +} + +func (f *fakeSessionStore) GetHistory(_ context.Context, key string) []providers.Message { + return f.history[key] +} + +func (f *fakeSessionStore) AddMessage(_ context.Context, key string, msg providers.Message) { + f.history[key] = append(f.history[key], msg) +} + +func (f *fakeSessionStore) SetLabel(_ context.Context, key, label string) { + f.labels[key] = label +} + +func (f *fakeSessionStore) UpdateMetadata(_ context.Context, _, _, _, _ string) {} + +func (f *fakeSessionStore) SetSessionMetadata(_ context.Context, key string, metadata map[string]string) { + f.metadata[key] = metadata +} + +func (f *fakeSessionStore) Delete(_ context.Context, key string) error { + if f.deleteErr != nil { + return f.deleteErr + } + f.deleted = append(f.deleted, key) + delete(f.sessions, key) + return nil +} + +func (f *fakeSessionStore) Reset(_ context.Context, key string) { + f.resetKeys = append(f.resetKeys, key) +} + +func (f *fakeSessionStore) TruncateHistory(_ context.Context, key string, keepLast int) { + h := f.history[key] + if len(h) > keepLast { + f.history[key] = h[len(h)-keepLast:] + } +} + +// ---- fakeSkillStore (+ manage) ---- + +type fakeSkillStore struct { + store.SkillStore + skills map[string]store.SkillInfo + bumpCount int + updateCalls map[uuid.UUID]map[string]any + updateErr error +} + +func newFakeSkillStore() *fakeSkillStore { + return &fakeSkillStore{ + skills: map[string]store.SkillInfo{}, + updateCalls: map[uuid.UUID]map[string]any{}, + } +} + +func (f *fakeSkillStore) ListSkills(_ context.Context) []store.SkillInfo { + var out []store.SkillInfo + for _, s := range f.skills { + out = append(out, s) + } + return out +} + +func (f *fakeSkillStore) GetSkill(_ context.Context, name string) (*store.SkillInfo, bool) { + s, ok := f.skills[name] + if !ok { + return nil, false + } + return &s, true +} + +func (f *fakeSkillStore) BumpVersion() { + f.bumpCount++ +} + +// fakeSkillManageStore implements store.SkillManageStore (SkillStore + +// management CRUD). It does not embed fakeSkillStore directly: both +// fakeSkillStore and store.SkillManageStore separately embed store.SkillStore +// (nil), and Go forbids ambiguous promoted-method satisfaction of an +// interface — so this type re-implements the read path against its own maps +// instead of delegating to a fakeSkillStore instance. +type fakeSkillManageStore struct { + store.SkillManageStore + skills map[string]store.SkillInfo + bumpCount int + updateCalls map[uuid.UUID]map[string]any + updateErr error +} + +func newFakeSkillManageStore() *fakeSkillManageStore { + return &fakeSkillManageStore{ + skills: map[string]store.SkillInfo{}, + updateCalls: map[uuid.UUID]map[string]any{}, + } +} + +func (f *fakeSkillManageStore) ListSkills(_ context.Context) []store.SkillInfo { + var out []store.SkillInfo + for _, s := range f.skills { + out = append(out, s) + } + return out +} + +func (f *fakeSkillManageStore) GetSkill(_ context.Context, name string) (*store.SkillInfo, bool) { + s, ok := f.skills[name] + if !ok { + return nil, false + } + return &s, true +} + +func (f *fakeSkillManageStore) BumpVersion() { + f.bumpCount++ +} + +func (f *fakeSkillManageStore) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error { + if f.updateErr != nil { + return f.updateErr + } + f.updateCalls[id] = updates + return nil +} + +// ---- fakeCronStore ---- + +type fakeCronStore struct { + store.CronStore + jobs map[string]*store.CronJob + removeErr error + updateErr error + runErr error + runLogTotal int +} + +func newFakeCronStore() *fakeCronStore { + return &fakeCronStore{jobs: map[string]*store.CronJob{}} +} + +func (f *fakeCronStore) AddJob(_ context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { + job := &store.CronJob{ + ID: uuid.NewString(), + Name: name, + Schedule: schedule, + Enabled: true, + } + f.jobs[job.ID] = job + return job, nil +} + +func (f *fakeCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) { + j, ok := f.jobs[jobID] + return j, ok +} + +func (f *fakeCronStore) ListJobs(_ context.Context, includeDisabled bool, _, _ string) []store.CronJob { + var out []store.CronJob + for _, j := range f.jobs { + if !includeDisabled && !j.Enabled { + continue + } + out = append(out, *j) + } + return out +} + +func (f *fakeCronStore) RemoveJob(_ context.Context, jobID string) error { + if f.removeErr != nil { + return f.removeErr + } + if _, ok := f.jobs[jobID]; !ok { + return errors.New("job not found") + } + delete(f.jobs, jobID) + return nil +} + +func (f *fakeCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + j, ok := f.jobs[jobID] + if !ok { + return nil, errors.New("job not found") + } + if patch.Name != "" { + j.Name = patch.Name + } + if patch.Enabled != nil { + j.Enabled = *patch.Enabled + } + return j, nil +} + +func (f *fakeCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error { + j, ok := f.jobs[jobID] + if !ok { + return errors.New("job not found") + } + j.Enabled = enabled + return nil +} + +func (f *fakeCronStore) RunJob(_ context.Context, jobID string, force bool) (bool, string, error) { + if f.runErr != nil { + return false, "", f.runErr + } + if _, ok := f.jobs[jobID]; !ok { + return false, "", errors.New("job not found") + } + return true, "", nil +} + +func (f *fakeCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]store.CronRunLogEntry, int) { + return nil, f.runLogTotal +} + +func (f *fakeCronStore) Status() map[string]any { + return map[string]any{"jobs": len(f.jobs)} +} + +// ---- fakeHookStore (mirrors internal/gateway/methods/hooks_test.go's fakeStore) ---- + +type fakeHookStore struct { + created map[uuid.UUID]hooks.HookConfig + createErr error + updateErr error + deleteErr error +} + +func newFakeHookStore() *fakeHookStore { + return &fakeHookStore{created: map[uuid.UUID]hooks.HookConfig{}} +} + +func (f *fakeHookStore) Create(_ context.Context, cfg hooks.HookConfig) (uuid.UUID, error) { + if f.createErr != nil { + return uuid.Nil, f.createErr + } + id := uuid.New() + cfg.ID = id + f.created[id] = cfg + return id, nil +} + +func (f *fakeHookStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) { + if cfg, ok := f.created[id]; ok { + return &cfg, nil + } + return nil, nil +} + +func (f *fakeHookStore) List(_ context.Context, _ hooks.ListFilter) ([]hooks.HookConfig, error) { + out := make([]hooks.HookConfig, 0, len(f.created)) + for _, cfg := range f.created { + out = append(out, cfg) + } + return out, nil +} + +func (f *fakeHookStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + if f.updateErr != nil { + return f.updateErr + } + cfg, ok := f.created[id] + if !ok { + return errors.New("not found") + } + if v, ok := updates["enabled"].(bool); ok { + cfg.Enabled = v + } + f.created[id] = cfg + return nil +} + +func (f *fakeHookStore) Delete(_ context.Context, id uuid.UUID) error { + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.created[id]; !ok { + return errors.New("not found") + } + delete(f.created, id) + return nil +} + +func (f *fakeHookStore) ResolveForEvent(_ context.Context, _ hooks.Event) ([]hooks.HookConfig, error) { + return nil, nil +} + +func (f *fakeHookStore) WriteExecution(_ context.Context, _ hooks.HookExecution) error { return nil } +func (f *fakeHookStore) SetHookAgents(_ context.Context, _ uuid.UUID, _ []uuid.UUID) error { + return nil +} +func (f *fakeHookStore) GetHookAgents(_ context.Context, _ uuid.UUID) ([]uuid.UUID, error) { + return nil, nil +} + +// ---- fakeHeartbeatStore ---- + +type fakeHeartbeatStore struct { + store.HeartbeatStore + byAgent map[uuid.UUID]*store.AgentHeartbeat + upsertErr error +} + +func newFakeHeartbeatStore() *fakeHeartbeatStore { + return &fakeHeartbeatStore{byAgent: map[uuid.UUID]*store.AgentHeartbeat{}} +} + +func (f *fakeHeartbeatStore) Get(_ context.Context, agentID uuid.UUID) (*store.AgentHeartbeat, error) { + if h, ok := f.byAgent[agentID]; ok { + return h, nil + } + return nil, sql.ErrNoRows +} + +func (f *fakeHeartbeatStore) Upsert(_ context.Context, hb *store.AgentHeartbeat) error { + if f.upsertErr != nil { + return f.upsertErr + } + f.byAgent[hb.AgentID] = hb + return nil +} + +func (f *fakeHeartbeatStore) ListLogs(_ context.Context, _ uuid.UUID, _, _ int) ([]store.HeartbeatRunLog, int, error) { + return nil, 0, nil +} + +func (f *fakeHeartbeatStore) ListDeliveryTargets(_ context.Context, _ uuid.UUID) ([]store.DeliveryTarget, error) { + return nil, nil +} + +// ---- fakePairingStore ---- + +type fakePairingStore struct { + store.PairingStore + pending []store.PairingRequestData + paired []store.PairedDeviceData + requestErr error + approveErr error + denyErr error + revokeErr error +} + +func newFakePairingStore() *fakePairingStore { + return &fakePairingStore{} +} + +func (f *fakePairingStore) RequestPairing(_ context.Context, senderID, channel, chatID, accountID string, _ map[string]string) (string, error) { + if f.requestErr != nil { + return "", f.requestErr + } + code := "CODE123" + f.pending = append(f.pending, store.PairingRequestData{Code: code, SenderID: senderID, Channel: channel, ChatID: chatID, AccountID: accountID}) + return code, nil +} + +func (f *fakePairingStore) ApprovePairing(_ context.Context, code, approvedBy string) (*store.PairedDeviceData, error) { + if f.approveErr != nil { + return nil, f.approveErr + } + for i, p := range f.pending { + if p.Code == code { + f.pending = append(f.pending[:i], f.pending[i+1:]...) + dev := store.PairedDeviceData{SenderID: p.SenderID, Channel: p.Channel, ChatID: p.ChatID, PairedBy: approvedBy} + f.paired = append(f.paired, dev) + return &dev, nil + } + } + return nil, errors.New("code not found") +} + +func (f *fakePairingStore) DenyPairing(_ context.Context, code string) error { + if f.denyErr != nil { + return f.denyErr + } + for i, p := range f.pending { + if p.Code == code { + f.pending = append(f.pending[:i], f.pending[i+1:]...) + return nil + } + } + return errors.New("code not found") +} + +func (f *fakePairingStore) RevokePairing(_ context.Context, senderID, channel string) error { + if f.revokeErr != nil { + return f.revokeErr + } + for i, p := range f.paired { + if p.SenderID == senderID && p.Channel == channel { + f.paired = append(f.paired[:i], f.paired[i+1:]...) + return nil + } + } + return errors.New("not paired") +} + +func (f *fakePairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { + for _, p := range f.paired { + if p.SenderID == senderID && p.Channel == channel { + return true, nil + } + } + return false, nil +} + +func (f *fakePairingStore) ListPending(_ context.Context) []store.PairingRequestData { + return f.pending +} + +func (f *fakePairingStore) ListPaired(_ context.Context) []store.PairedDeviceData { + return f.paired +} + +// ---- fakeTeamStore ---- + +type fakeTeamStore struct { + store.TeamStore + teams map[uuid.UUID]*store.TeamData + members map[uuid.UUID][]store.TeamMemberData + createErr error + getErr error + deleteErr error +} + +func newFakeTeamStore() *fakeTeamStore { + return &fakeTeamStore{ + teams: map[uuid.UUID]*store.TeamData{}, + members: map[uuid.UUID][]store.TeamMemberData{}, + } +} + +func (f *fakeTeamStore) CreateTeam(_ context.Context, team *store.TeamData) error { + if f.createErr != nil { + return f.createErr + } + if team.ID == uuid.Nil { + team.ID = uuid.New() + } + f.teams[team.ID] = team + return nil +} + +func (f *fakeTeamStore) GetTeam(_ context.Context, teamID uuid.UUID) (*store.TeamData, error) { + if f.getErr != nil { + return nil, f.getErr + } + t, ok := f.teams[teamID] + if !ok { + return nil, errors.New("team not found") + } + return t, nil +} + +func (f *fakeTeamStore) ListTeams(_ context.Context) ([]store.TeamData, error) { + var out []store.TeamData + for _, t := range f.teams { + out = append(out, *t) + } + return out, nil +} + +func (f *fakeTeamStore) DeleteTeam(_ context.Context, teamID uuid.UUID) error { + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.teams[teamID]; !ok { + return errors.New("team not found") + } + delete(f.teams, teamID) + return nil +} + +func (f *fakeTeamStore) UpdateTeam(_ context.Context, teamID uuid.UUID, updates map[string]any) error { + t, ok := f.teams[teamID] + if !ok { + return errors.New("team not found") + } + if v, ok := updates["name"].(string); ok { + t.Name = v + } + return nil +} + +func (f *fakeTeamStore) AddMember(_ context.Context, teamID, agentID uuid.UUID, role string) error { + f.members[teamID] = append(f.members[teamID], store.TeamMemberData{TeamID: teamID, AgentID: agentID, Role: role}) + return nil +} + +func (f *fakeTeamStore) RemoveMember(_ context.Context, teamID, agentID uuid.UUID) error { + members := f.members[teamID] + for i, m := range members { + if m.AgentID == agentID { + f.members[teamID] = append(members[:i], members[i+1:]...) + return nil + } + } + return errors.New("member not found") +} + +func (f *fakeTeamStore) ListMembers(_ context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { + return f.members[teamID], nil +} + +func (f *fakeTeamStore) GetTeamForAgent(_ context.Context, agentID uuid.UUID) (*store.TeamData, error) { + for _, t := range f.teams { + if t.LeadAgentID == agentID { + return t, nil + } + } + return nil, nil +} + +func (f *fakeTeamStore) KnownUserIDs(_ context.Context, _ uuid.UUID, _ int) ([]string, error) { + return nil, nil +} + +func (f *fakeTeamStore) ListTaskScopes(_ context.Context, _ uuid.UUID) ([]store.ScopeEntry, error) { + return nil, nil +} + +func (f *fakeTeamStore) ListTeamEvents(_ context.Context, _ uuid.UUID, _, _ int) ([]store.TeamTaskEventData, error) { + return nil, nil +} + +// ---- fakeChannelInstanceStore ---- + +type fakeChannelInstanceStore struct { + store.ChannelInstanceStore + byID map[uuid.UUID]*store.ChannelInstanceData + createErr error + deleteErr error +} + +func newFakeChannelInstanceStore() *fakeChannelInstanceStore { + return &fakeChannelInstanceStore{byID: map[uuid.UUID]*store.ChannelInstanceData{}} +} + +func (f *fakeChannelInstanceStore) Create(_ context.Context, inst *store.ChannelInstanceData) error { + if f.createErr != nil { + return f.createErr + } + if inst.ID == uuid.Nil { + inst.ID = uuid.New() + } + f.byID[inst.ID] = inst + return nil +} + +func (f *fakeChannelInstanceStore) Get(_ context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) { + if inst, ok := f.byID[id]; ok { + return inst, nil + } + return nil, errors.New("instance not found") +} + +func (f *fakeChannelInstanceStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error { + inst, ok := f.byID[id] + if !ok { + return errors.New("instance not found") + } + if v, ok := updates["enabled"].(bool); ok { + inst.Enabled = v + } + return nil +} + +func (f *fakeChannelInstanceStore) Delete(_ context.Context, id uuid.UUID) error { + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.byID[id]; !ok { + return errors.New("instance not found") + } + delete(f.byID, id) + return nil +} + +func (f *fakeChannelInstanceStore) ListAll(_ context.Context) ([]store.ChannelInstanceData, error) { + var out []store.ChannelInstanceData + for _, inst := range f.byID { + out = append(out, *inst) + } + return out, nil +} + +// ---- fakeProviderStore ---- + +type fakeProviderStore struct { + store.ProviderStore + byName map[string]*store.LLMProviderData +} + +func newFakeProviderStore() *fakeProviderStore { + return &fakeProviderStore{byName: map[string]*store.LLMProviderData{}} +} + +func (f *fakeProviderStore) GetProviderByName(_ context.Context, name string) (*store.LLMProviderData, error) { + if p, ok := f.byName[name]; ok { + return p, nil + } + return nil, errors.New("provider not found") +} + +// ---- fakeTenantStore ---- + +type fakeTenantStore struct { + store.TenantStore + byID map[uuid.UUID]*store.TenantData + bySlug map[string]*store.TenantData +} + +func newFakeTenantStore() *fakeTenantStore { + return &fakeTenantStore{ + byID: map[uuid.UUID]*store.TenantData{}, + bySlug: map[string]*store.TenantData{}, + } +} + +func (f *fakeTenantStore) addTenant(t *store.TenantData) { + f.byID[t.ID] = t + f.bySlug[t.Slug] = t +} + +func (f *fakeTenantStore) GetTenant(_ context.Context, id uuid.UUID) (*store.TenantData, error) { + if t, ok := f.byID[id]; ok { + return t, nil + } + return nil, errors.New("tenant not found") +} + +func (f *fakeTenantStore) GetTenantBySlug(_ context.Context, slug string) (*store.TenantData, error) { + if t, ok := f.bySlug[slug]; ok { + return t, nil + } + return nil, errors.New("tenant not found") +} + +// ---- fakeChatRunner ---- + +type fakeChatRunner struct { + sendResult *ChatSendResult + sendErr error + abortResult *ChatAbortResult + abortErr error + statusResult *ChatSessionStatusResult + statusErr error + lastAgentID string + lastSessKey string + lastMessage string +} + +func (f *fakeChatRunner) Send(_ context.Context, agentID, sessionKey, message string, _ []ChatMediaItem) (*ChatSendResult, error) { + f.lastAgentID = agentID + f.lastSessKey = sessionKey + f.lastMessage = message + if f.sendErr != nil { + return nil, f.sendErr + } + if f.sendResult != nil { + return f.sendResult, nil + } + return &ChatSendResult{RunID: "run-1", Content: "ok"}, nil +} + +func (f *fakeChatRunner) Abort(_ context.Context, _, _ string) (*ChatAbortResult, error) { + if f.abortErr != nil { + return nil, f.abortErr + } + if f.abortResult != nil { + return f.abortResult, nil + } + return &ChatAbortResult{OK: true}, nil +} + +func (f *fakeChatRunner) SessionStatus(_ context.Context, _ string) (*ChatSessionStatusResult, error) { + if f.statusErr != nil { + return nil, f.statusErr + } + if f.statusResult != nil { + return f.statusResult, nil + } + return &ChatSessionStatusResult{IsRunning: false}, nil +} diff --git a/internal/mcp/crud_heartbeat.go b/internal/mcp/crud_heartbeat.go new file mode 100644 index 00000000..f827e8ae --- /dev/null +++ b/internal/mcp/crud_heartbeat.go @@ -0,0 +1,341 @@ +package mcp + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// minHeartbeatIntervalSec mirrors internal/gateway/methods/heartbeat.go's +// inline "minimum interval is 300 seconds" check. +const minHeartbeatIntervalSec = 300 + +// maxHeartbeatRetries mirrors internal/gateway/methods/heartbeat.go's inline +// "maxRetries must be 0-10" check. +const maxHeartbeatRetries = 10 + +// registerHeartbeatCRUDTools registers the goclaw_heartbeat_* MCP tools +// backed by store.HeartbeatStore. Mirrors internal/gateway/methods/heartbeat.go +// minus heartbeat.test (no wake function is available on this standalone MCP +// surface — see final report) and minus the cache-invalidation/audit-event +// side effects (WS-only concerns). +func registerHeartbeatCRUDTools(srv *mcpserver.MCPServer, hb store.HeartbeatStore, agents store.AgentStore, providers store.ProviderStore) { + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_get", + mcpgo.WithDescription("Get an agent's heartbeat configuration."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHeartbeatGet(hb, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_set", + mcpgo.WithDescription("Create or update an agent's heartbeat configuration."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state.")), + mcpgo.WithNumber("interval_sec", mcpgo.Description("Interval in seconds (minimum 300).")), + mcpgo.WithString("prompt", mcpgo.Description("Heartbeat prompt override.")), + mcpgo.WithString("provider_name", mcpgo.Description("Provider name override; empty string clears the override.")), + mcpgo.WithString("model", mcpgo.Description("Model override; empty string clears the override.")), + mcpgo.WithBoolean("isolated_session", mcpgo.Description("Run heartbeat in an isolated session.")), + mcpgo.WithBoolean("light_context", mcpgo.Description("Use light context for the heartbeat run.")), + mcpgo.WithNumber("ack_max_chars", mcpgo.Description("Max chars for acknowledgement (>= 0).")), + mcpgo.WithNumber("max_retries", mcpgo.Description("Max retries (0-10).")), + mcpgo.WithString("active_hours_start", mcpgo.Description("Active hours window start (HH:MM).")), + mcpgo.WithString("active_hours_end", mcpgo.Description("Active hours window end (HH:MM).")), + mcpgo.WithString("timezone", mcpgo.Description("IANA timezone.")), + mcpgo.WithString("channel", mcpgo.Description("Delivery channel override.")), + mcpgo.WithString("chat_id", mcpgo.Description("Delivery chat ID override.")), + ), handleHeartbeatSet(hb, agents, providers)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_toggle", + mcpgo.WithDescription("Enable or disable an agent's heartbeat."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithBoolean("enabled", mcpgo.Required(), mcpgo.Description("Desired enabled state.")), + ), handleHeartbeatToggle(hb, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_test", + mcpgo.WithDescription("Trigger an immediate heartbeat run. NOTE: not available on this MCP surface — no heartbeat ticker wake function is wired here (see internal/gateway/methods/heartbeat.go's SetWakeFn, only attached to the WS RPC surface); always returns an error."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + ), handleHeartbeatTest()) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_logs", + mcpgo.WithDescription("List heartbeat run log entries for an agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return.")), + mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHeartbeatLogs(hb, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_checklist_get", + mcpgo.WithDescription("Read an agent's HEARTBEAT.md checklist content."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHeartbeatChecklistGet(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_checklist_set", + mcpgo.WithDescription("Write an agent's HEARTBEAT.md checklist content."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID.")), + mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("New HEARTBEAT.md content.")), + ), handleHeartbeatChecklistSet(agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_heartbeat_targets", + mcpgo.WithDescription("List known (channel, chatID) delivery targets for the current tenant."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHeartbeatTargets(hb)) +} + +func handleHeartbeatGet(hb store.HeartbeatStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.get", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.get", fmt.Errorf("invalid agent_id: %w", err)) + } + h, err := hb.Get(ctx, agentID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return jsonToolResult(map[string]any{"heartbeat": nil}) + } + return toolError("heartbeat.get", err) + } + return jsonToolResult(map[string]any{"heartbeat": h}) + } +} + +func handleHeartbeatSet(hb store.HeartbeatStore, agents store.AgentStore, providers store.ProviderStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.set", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.set", fmt.Errorf("invalid agent_id: %w", err)) + } + + h, err := hb.Get(ctx, agentID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return toolError("heartbeat.set", err) + } + const ( + defaultIntervalSec = 1800 + defaultAckMaxChars = 300 + defaultMaxRetries = 2 + ) + if h == nil { + h = &store.AgentHeartbeat{ + AgentID: agentID, IntervalSec: defaultIntervalSec, IsolatedSession: true, + AckMaxChars: defaultAckMaxChars, MaxRetries: defaultMaxRetries, + } + } + + args := req.GetArguments() + if v, ok := args["enabled"].(bool); ok { + h.Enabled = v + } + if v, ok := args["interval_sec"].(float64); ok { + if int(v) < minHeartbeatIntervalSec { + return mcpgo.NewToolResultError("heartbeat.set: minimum interval is 300 seconds"), nil + } + h.IntervalSec = int(v) + } + if v, ok := args["prompt"].(string); ok { + h.Prompt = &v + } + if v, ok := args["provider_name"].(string); ok { + if v == "" { + h.ProviderID = nil + } else if providers != nil { + prov, err := providers.GetProviderByName(ctx, v) + if err != nil { + return mcpgo.NewToolResultError("heartbeat.set: provider not found: " + v), nil + } + h.ProviderID = &prov.ID + } + } + if v, ok := args["model"].(string); ok { + if v == "" { + h.Model = nil + } else { + h.Model = &v + } + } + if v, ok := args["isolated_session"].(bool); ok { + h.IsolatedSession = v + } + if v, ok := args["light_context"].(bool); ok { + h.LightContext = v + } + if v, ok := args["ack_max_chars"].(float64); ok { + if int(v) < 0 { + return mcpgo.NewToolResultError("heartbeat.set: ack_max_chars must be >= 0"), nil + } + h.AckMaxChars = int(v) + } + if v, ok := args["max_retries"].(float64); ok { + if int(v) < 0 || int(v) > maxHeartbeatRetries { + return mcpgo.NewToolResultError("heartbeat.set: max_retries must be 0-10"), nil + } + h.MaxRetries = int(v) + } + if v, ok := args["active_hours_start"].(string); ok { + h.ActiveHoursStart = &v + } + if v, ok := args["active_hours_end"].(string); ok { + h.ActiveHoursEnd = &v + } + if v, ok := args["timezone"].(string); ok { + if v != "" { + if _, err := time.LoadLocation(v); err != nil { + return mcpgo.NewToolResultError("heartbeat.set: invalid timezone: " + v), nil + } + } + h.Timezone = &v + } + if v, ok := args["channel"].(string); ok { + h.Channel = &v + } + if v, ok := args["chat_id"].(string); ok { + h.ChatID = &v + } + + if h.Enabled && h.NextRunAt == nil { + nextRun := time.Now().Add(time.Duration(h.IntervalSec)*time.Second + store.StaggerOffset(h.AgentID, h.IntervalSec)) + h.NextRunAt = &nextRun + } + + if err := hb.Upsert(ctx, h); err != nil { + return toolError("heartbeat.set", err) + } + return jsonToolResult(map[string]any{"heartbeat": h}) + } +} + +func handleHeartbeatToggle(hb store.HeartbeatStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.toggle", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.toggle", fmt.Errorf("invalid agent_id: %w", err)) + } + enabled, err := req.RequireBool("enabled") + if err != nil { + return toolError("heartbeat.toggle", err) + } + h, err := hb.Get(ctx, agentID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return mcpgo.NewToolResultError("heartbeat.toggle: heartbeat not configured"), nil + } + return toolError("heartbeat.toggle", err) + } + h.Enabled = enabled + if enabled && h.NextRunAt == nil { + nextRun := time.Now().Add(time.Duration(h.IntervalSec) * time.Second) + h.NextRunAt = &nextRun + } + if err := hb.Upsert(ctx, h); err != nil { + return toolError("heartbeat.toggle", err) + } + return jsonToolResult(map[string]any{"agentId": agentRef, "enabled": enabled}) + } +} + +func handleHeartbeatTest() mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultError("heartbeat.test: not available on this MCP surface (no heartbeat ticker wake function wired)"), nil + } +} + +func handleHeartbeatLogs(hb store.HeartbeatStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.logs", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.logs", fmt.Errorf("invalid agent_id: %w", err)) + } + limit := int(req.GetFloat("limit", 0)) + offset := int(req.GetFloat("offset", 0)) + logs, total, err := hb.ListLogs(ctx, agentID, limit, offset) + if err != nil { + return toolError("heartbeat.logs", err) + } + return jsonToolResult(map[string]any{"logs": logs, "total": total}) + } +} + +func handleHeartbeatChecklistGet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.checklist.get", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.checklist.get", fmt.Errorf("invalid agent_id: %w", err)) + } + files, err := agents.GetAgentContextFiles(ctx, agentID) + if err != nil { + return toolError("heartbeat.checklist.get", err) + } + var content string + for _, f := range files { + if f.FileName == "HEARTBEAT.md" { + content = f.Content + break + } + } + return jsonToolResult(map[string]any{"content": content}) + } +} + +func handleHeartbeatChecklistSet(agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("heartbeat.checklist.set", err) + } + content, err := req.RequireString("content") + if err != nil { + return toolError("heartbeat.checklist.set", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("heartbeat.checklist.set", fmt.Errorf("invalid agent_id: %w", err)) + } + if err := agents.SetAgentContextFile(ctx, agentID, "HEARTBEAT.md", content); err != nil { + return toolError("heartbeat.checklist.set", err) + } + return jsonToolResult(map[string]any{"ok": true, "length": len([]rune(content))}) + } +} + +func handleHeartbeatTargets(hb store.HeartbeatStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + targets, err := hb.ListDeliveryTargets(ctx, tenantID) + if err != nil { + return toolError("heartbeat.targets", err) + } + return jsonToolResult(map[string]any{"targets": targets}) + } +} diff --git a/internal/mcp/crud_heartbeat_test.go b/internal/mcp/crud_heartbeat_test.go new file mode 100644 index 00000000..75be9c86 --- /dev/null +++ b/internal/mcp/crud_heartbeat_test.go @@ -0,0 +1,104 @@ +package mcp + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestHeartbeatGet_NotConfigured(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "a"}) + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_get", map[string]any{"agent_id": "a"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), `"heartbeat":null`) +} + +func TestHeartbeatGet_InvalidAgent(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_get", map[string]any{"agent_id": "missing"}) + assert.True(t, toolIsError(result)) +} + +func TestHeartbeatSet_RejectsIntervalBelowMinimum(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "a"}) + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_set", map[string]any{"agent_id": "a", "interval_sec": float64(60)}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "minimum interval") +} + +func TestHeartbeatSet_HappyPath(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "a"}) + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_set", map[string]any{ + "agent_id": "a", "enabled": true, "interval_sec": float64(600), + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Equal(t, 600, hb.byAgent[agentID].IntervalSec) + assert.True(t, hb.byAgent[agentID].Enabled) +} + +func TestHeartbeatToggle_NotConfigured(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "a"}) + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_toggle", map[string]any{"agent_id": "a", "enabled": true}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "not configured") +} + +// TestHeartbeatTest_AlwaysUnavailable guards the documented stub behavior. +func TestHeartbeatTest_AlwaysUnavailable(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + result := callTool(t, srv, "goclaw_heartbeat_test", map[string]any{"agent_id": "a"}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "not available") +} + +func TestHeartbeatChecklist_SetAndGet(t *testing.T) { + hb := newFakeHeartbeatStore() + agents := newFakeAgentStore() + agentID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: agentID}, AgentKey: "a"}) + srv := newTestMCPServer() + registerHeartbeatCRUDTools(srv, hb, agents, nil) + + setResult := callTool(t, srv, "goclaw_heartbeat_checklist_set", map[string]any{"agent_id": "a", "content": "- check email"}) + require.False(t, toolIsError(setResult)) + + getResult := callTool(t, srv, "goclaw_heartbeat_checklist_get", map[string]any{"agent_id": "a"}) + require.False(t, toolIsError(getResult)) + assert.Contains(t, toolResultText(getResult), "check email") +} diff --git a/internal/mcp/crud_helpers.go b/internal/mcp/crud_helpers.go new file mode 100644 index 00000000..3cb7c14a --- /dev/null +++ b/internal/mcp/crud_helpers.go @@ -0,0 +1,164 @@ +package mcp + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// jsonToolResult marshals v as an MCP text tool result. Marshal failures are +// reported as tool errors rather than propagated as transport errors, since +// they indicate a bug in the value being returned, not a request problem. +func jsonToolResult(v any) (*mcpgo.CallToolResult, error) { + data, err := json.Marshal(v) + if err != nil { + return mcpgo.NewToolResultError("marshal result: " + err.Error()), nil + } + return mcpgo.NewToolResultText(string(data)), nil +} + +// toolError wraps an error as an MCP tool-level error result (not a +// transport-level error), so the calling LLM sees the failure reason in the +// tool response instead of the call silently failing. +func toolError(prefix string, err error) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultError(prefix + ": " + err.Error()), nil +} + +// resolveAgentUUID resolves an agent identifier (either UUID or agent_key) to +// its canonical UUID via a DB lookup. Mirrors +// internal/gateway/methods/agent_links.go's unexported helper of the same +// name — duplicated rather than imported since crud_*.go is a standalone MCP +// surface that does not depend on internal/gateway/methods (which itself +// depends on internal/gateway, which imports this package — importing +// methods here would create an import cycle). +func resolveAgentUUID(ctx context.Context, agents store.AgentStore, keyOrID string) (uuid.UUID, error) { + if id, err := uuid.Parse(keyOrID); err == nil { + ag, err := agents.GetByID(ctx, id) + if err != nil { + return uuid.Nil, err + } + return ag.ID, nil + } + ag, err := agents.GetByKey(ctx, keyOrID) + if err != nil { + return uuid.Nil, err + } + return ag.ID, nil +} + +// resolveAgentInfo returns full agent data for an agent identifier (UUID or +// agent_key). See resolveAgentUUID for why this is duplicated here. +func resolveAgentInfo(ctx context.Context, agents store.AgentStore, keyOrID string) (*store.AgentData, error) { + if id, err := uuid.Parse(keyOrID); err == nil { + return agents.GetByID(ctx, id) + } + return agents.GetByKey(ctx, keyOrID) +} + +// mcpTenantHeader is the HTTP header a CRUD MCP caller may use to scope a +// request to a specific tenant, mirroring internal/http/auth.go's +// "X-GoClaw-Tenant-Id" header used by the gateway-token (owner) and +// system-level API key auth paths. The MCP bearer token +// (gateway.mcp_server_token) is a single shared secret with no per-caller +// identity, so it is treated the same way the owner branch of +// resolveAuthWithBearer treats the gateway token: any tenant may be +// requested, with no membership check, falling back to store.MasterTenantID +// when absent or unresolvable. +const mcpTenantHeader = "X-GoClaw-Tenant-Id" + +// resolveMCPTenantID resolves the caller-supplied tenant header (UUID or +// slug) to a concrete tenant UUID using tenants, falling back to +// store.MasterTenantID when the header is empty, unresolvable, or tenants is +// nil. This is the sole place tenant scope is established for the CRUD MCP +// surface (see NewCRUDServer's mcpserver.WithHTTPContextFunc wiring) — every +// tool handler in this package relies on the incoming ctx already carrying a +// concrete tenant ID via store.WithTenantID. +func resolveMCPTenantID(ctx context.Context, tenants store.TenantStore, headerVal string) uuid.UUID { + if headerVal == "" || tenants == nil { + return store.MasterTenantID + } + if id, err := uuid.Parse(headerVal); err == nil { + if t, err := tenants.GetTenant(ctx, id); err == nil && t != nil { + return t.ID + } + return store.MasterTenantID + } + if t, err := tenants.GetTenantBySlug(ctx, headerVal); err == nil && t != nil { + return t.ID + } + return store.MasterTenantID +} + +// ChatMediaItem represents a media file attached to a goclaw_chat_send call, +// mirroring internal/gateway/methods/chat.go's chatMediaItem. +type ChatMediaItem struct { + Path string + Filename string +} + +// ChatSendResult is the outcome of a goclaw_chat_send call. +type ChatSendResult struct { + RunID string `json:"runId"` + Content string `json:"content"` + Usage any `json:"usage,omitempty"` + Thinking string `json:"thinking,omitempty"` + Media any `json:"media,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` +} + +// ChatAbortResult is the outcome of a goclaw_chat_abort call, mirroring +// internal/gateway/methods/chat.go's handleAbort response shape. +type ChatAbortResult struct { + OK bool `json:"ok"` + Aborted bool `json:"aborted"` + Stopped bool `json:"stopped"` + Forced bool `json:"forced"` + AlreadyAborting bool `json:"alreadyAborting"` + NotFound bool `json:"notFound"` + RunIDs []string `json:"runIds"` +} + +// ChatActivity describes the current in-flight agent activity for a session. +type ChatActivity struct { + Phase string `json:"phase"` + Tool string `json:"tool"` + Iteration int `json:"iteration"` +} + +// ChatSessionStatusResult is the outcome of a goclaw_chat_session_status call. +type ChatSessionStatusResult struct { + IsRunning bool `json:"isRunning"` + RunID string `json:"runId"` + Activity *ChatActivity `json:"activity,omitempty"` +} + +// ChatRunner executes and controls agent chat runs on behalf of the CRUD MCP +// server. It is implemented by internal/gateway.Server (which holds the live +// *agent.Router) and injected here as an interface — internal/agent already +// imports internal/mcp (loop_mcp_user.go), so importing agent.Router directly +// in this package would create an import cycle. Same workaround as +// AgentRuntimeLookup in crud_agents.go. +// +// Unlike the WS chat.send/chat.abort/chat.session.status RPC methods, calls +// through this interface have no per-WS-client concept: no rate limiting, no +// send debouncing, and no session-ownership check against a caller identity. +// The MCP bearer token (gateway.mcp_server_token) is the sole security +// boundary here, matching the rest of this CRUD MCP surface (e.g. +// goclaw_sessions_* has no ownership checks either). +type ChatRunner interface { + // Send runs the agent for sessionKey (creating a new session key when + // empty) and returns the final result. Always synchronous/non-streaming: + // MCP tool calls are request/response, so there is no channel to forward + // incremental run events to the caller. + Send(ctx context.Context, agentID, sessionKey, message string, media []ChatMediaItem) (*ChatSendResult, error) + // Abort cancels the run(s) matching runID and/or sessionKey (at least one + // must be non-empty). + Abort(ctx context.Context, runID, sessionKey string) (*ChatAbortResult, error) + // SessionStatus reports whether sessionKey currently has an in-flight run. + SessionStatus(ctx context.Context, sessionKey string) (*ChatSessionStatusResult, error) +} diff --git a/internal/mcp/crud_helpers_test.go b/internal/mcp/crud_helpers_test.go new file mode 100644 index 00000000..0ece904c --- /dev/null +++ b/internal/mcp/crud_helpers_test.go @@ -0,0 +1,81 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestResolveAgentUUID_ByUUID(t *testing.T) { + agents := newFakeAgentStore() + id := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: id}, AgentKey: "support"}) + + got, err := resolveAgentUUID(context.Background(), agents, id.String()) + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestResolveAgentUUID_ByKey(t *testing.T) { + agents := newFakeAgentStore() + id := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: id}, AgentKey: "support"}) + + got, err := resolveAgentUUID(context.Background(), agents, "support") + require.NoError(t, err) + assert.Equal(t, id, got) +} + +func TestResolveAgentUUID_NotFound(t *testing.T) { + agents := newFakeAgentStore() + _, err := resolveAgentUUID(context.Background(), agents, "missing-agent") + assert.Error(t, err) +} + +func TestResolveAgentInfo_ByUUIDAndKey(t *testing.T) { + agents := newFakeAgentStore() + id := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: id}, AgentKey: "support", DisplayName: "Support"}) + + byID, err := resolveAgentInfo(context.Background(), agents, id.String()) + require.NoError(t, err) + assert.Equal(t, "Support", byID.DisplayName) + + byKey, err := resolveAgentInfo(context.Background(), agents, "support") + require.NoError(t, err) + assert.Equal(t, id, byKey.ID) +} + +func TestJSONToolResult_MarshalsValue(t *testing.T) { + result, err := jsonToolResult(map[string]string{"hello": "world"}) + require.NoError(t, err) + assert.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), `"hello":"world"`) +} + +func TestJSONToolResult_UnmarshalableValue_ReturnsToolError(t *testing.T) { + // channels cannot be JSON-marshaled; jsonToolResult should report this as + // a tool-level error rather than a Go error/panic. + result, err := jsonToolResult(make(chan int)) + require.NoError(t, err) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "marshal result") +} + +func TestToolError_WrapsPrefixAndMessage(t *testing.T) { + result, err := toolError("agents.get", assertErr("boom")) + require.NoError(t, err) + assert.True(t, toolIsError(result)) + assert.Equal(t, "agents.get: boom", toolResultText(result)) +} + +// assertErr is a tiny error constructor to avoid importing "errors" solely +// for one string-error test case. +type assertErr string + +func (e assertErr) Error() string { return string(e) } diff --git a/internal/mcp/crud_hooks.go b/internal/mcp/crud_hooks.go new file mode 100644 index 00000000..ec209df8 --- /dev/null +++ b/internal/mcp/crud_hooks.go @@ -0,0 +1,307 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/edition" + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +// registerHooksCRUDTools registers the goclaw_hooks_* MCP tools backed by +// hooks.HookStore. Mirrors internal/gateway/methods/hooks.go, minus the +// RBAC-role gate (this whole surface is gated by the CRUD MCP bearer token — +// see internal/mcp/crud_server.go doc comment) and minus hooks.test's dry-run +// support (no TestRunner is wired for this standalone MCP surface; skipped — +// see final report). +func registerHooksCRUDTools(srv *mcpserver.MCPServer, store hooks.HookStore) { + srv.AddTool(mcpgo.NewTool("goclaw_hooks_list", + mcpgo.WithDescription("List configured hooks, optionally filtered."), + mcpgo.WithString("event", mcpgo.Description("Filter by hook event.")), + mcpgo.WithString("scope", mcpgo.Description("Filter by scope (global, tenant, agent).")), + mcpgo.WithString("agent_id", mcpgo.Description("Filter by agent UUID.")), + mcpgo.WithBoolean("enabled", mcpgo.Description("Filter by enabled state.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHooksList(store)) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_create", + mcpgo.WithDescription("Create a new hook."), + mcpgo.WithObject("config", mcpgo.Required(), mcpgo.Description("Full hook config object (handler_type, event, scope, config, matcher, etc.).")), + ), handleHooksCreate(store)) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_update", + mcpgo.WithDescription("Apply a partial update to an existing hook."), + mcpgo.WithString("hook_id", mcpgo.Required(), mcpgo.Description("Hook UUID.")), + mcpgo.WithObject("updates", mcpgo.Required(), mcpgo.Description("Column→value patch.")), + ), handleHooksUpdate(store)) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_delete", + mcpgo.WithDescription("Delete a hook. Builtin hooks are read-only and cannot be deleted."), + mcpgo.WithString("hook_id", mcpgo.Required(), mcpgo.Description("Hook UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleHooksDelete(store)) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_toggle", + mcpgo.WithDescription("Enable or disable a hook."), + mcpgo.WithString("hook_id", mcpgo.Required(), mcpgo.Description("Hook UUID.")), + mcpgo.WithBoolean("enabled", mcpgo.Required(), mcpgo.Description("Desired enabled state.")), + ), handleHooksToggle(store)) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_test", + mcpgo.WithDescription("Dry-run a hook. NOTE: not available on this MCP surface — no dry-run test runner is wired here (see internal/gateway/methods/hooks.go's HookTestRunner, which is only attached to the WS RPC surface); always returns an error."), + mcpgo.WithObject("config", mcpgo.Required(), mcpgo.Description("Hook config to test.")), + mcpgo.WithObject("sample_event", mcpgo.Description("Sample event payload.")), + ), handleHooksTest()) + + srv.AddTool(mcpgo.NewTool("goclaw_hooks_history", + mcpgo.WithDescription("Return hook execution history. NOTE: matches the WS RPC twin's Phase 3 MVP stub — always returns an empty list (paginated reads are not yet implemented in HookStore)."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleHooksHistory()) +} + +func handleHooksList(store hooks.HookStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + filter := hooks.ListFilter{} + args := req.GetArguments() + if v, ok := args["enabled"].(bool); ok { + filter.Enabled = &v + } + if event := req.GetString("event", ""); event != "" { + ev := hooks.HookEvent(event) + filter.Event = &ev + } + if scope := req.GetString("scope", ""); scope != "" { + sc := hooks.Scope(scope) + filter.Scope = &sc + } + if agentID := req.GetString("agent_id", ""); agentID != "" { + if id, err := uuid.Parse(agentID); err == nil { + filter.AgentID = &id + } + } + list, err := store.List(ctx, filter) + if err != nil { + return toolError("hooks.list", err) + } + return jsonToolResult(map[string]any{"hooks": list}) + } +} + +// parseMCPHookConfig mirrors internal/gateway/methods/hooks.go's +// parseHookConfigParams — strips caller-controlled identity/provenance +// fields so a caller cannot forge a builtin-tier hook. +func parseMCPHookConfig(raw any) (*hooks.HookConfig, error) { + data, err := json.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + var cfg hooks.HookConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + if cfg.Metadata == nil { + cfg.Metadata = map[string]any{} + } + if cfg.Config == nil { + cfg.Config = map[string]any{} + } + if cfg.HandlerType == "" || cfg.Event == "" || cfg.Scope == "" { + return nil, errors.New("handler_type, event, and scope are required") + } + cfg.Source = "" + cfg.ID = uuid.Nil + cfg.CreatedBy = nil + cfg.Version = 0 + if len(cfg.AgentIDs) == 0 && cfg.AgentID != nil && *cfg.AgentID != uuid.Nil { + cfg.AgentIDs = []uuid.UUID{*cfg.AgentID} + } + return &cfg, nil +} + +func handleHooksCreate(store hooks.HookStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + args := req.GetArguments() + raw, ok := args["config"] + if !ok { + return mcpgo.NewToolResultError("hooks.create: config is required"), nil + } + cfg, err := parseMCPHookConfig(raw) + if err != nil { + return toolError("hooks.create", err) + } + if cfg.Scope == hooks.ScopeGlobal { + cfg.TenantID = hooks.SentinelTenantID + } + // This whole MCP CRUD surface is gated by a single bearer token + // (gateway.mcp_server_token) equivalent to full admin/master scope — + // same trust model as the rest of internal/mcp/crud_*.go — so global + // hook creation is allowed here without a per-caller master-scope check. + if err := cfg.Validate(edition.Current(), true); err != nil { + return toolError("hooks.create", err) + } + id, err := store.Create(ctx, *cfg) + if err != nil { + return toolError("hooks.create", err) + } + return jsonToolResult(map[string]string{"hookId": id.String()}) + } +} + +// applyHookPatch mirrors internal/gateway/methods/hooks.go's unexported +// helper of the same name. +func applyMCPHookPatch(cur hooks.HookConfig, p map[string]any) hooks.HookConfig { + if v, ok := p["name"].(string); ok { + cur.Name = v + } + if v, ok := p["agent_ids"]; ok { + if arr, ok := v.([]any); ok { + var ids []uuid.UUID + for _, item := range arr { + if s, ok := item.(string); ok { + if id, err := uuid.Parse(s); err == nil { + ids = append(ids, id) + } + } + } + cur.AgentIDs = ids + } + } + if v, ok := p["event"].(string); ok && v != "" { + cur.Event = hooks.HookEvent(v) + } + if v, ok := p["scope"].(string); ok && v != "" { + cur.Scope = hooks.Scope(v) + } + if v, ok := p["handler_type"].(string); ok && v != "" { + cur.HandlerType = hooks.HandlerType(v) + } + if v, ok := p["matcher"].(string); ok { + cur.Matcher = v + } + if v, ok := p["if_expr"].(string); ok { + cur.IfExpr = v + } + if v, ok := p["timeout_ms"].(float64); ok { + cur.TimeoutMS = int(v) + } + if v, ok := p["on_timeout"].(string); ok && v != "" { + cur.OnTimeout = hooks.Decision(v) + } + if v, ok := p["priority"].(float64); ok { + cur.Priority = int(v) + } + if v, ok := p["enabled"].(bool); ok { + cur.Enabled = v + } + if v, ok := p["config"].(map[string]any); ok { + cur.Config = v + } + if v, ok := p["metadata"].(map[string]any); ok { + cur.Metadata = v + } + return cur +} + +func handleHooksUpdate(store hooks.HookStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + hookIDStr, err := req.RequireString("hook_id") + if err != nil { + return toolError("hooks.update", err) + } + id, err := uuid.Parse(hookIDStr) + if err != nil { + return toolError("hooks.update", fmt.Errorf("invalid hook_id: %w", err)) + } + args := req.GetArguments() + updates, _ := args["updates"].(map[string]any) + if len(updates) == 0 { + return mcpgo.NewToolResultError("hooks.update: updates is required"), nil + } + delete(updates, "id") + delete(updates, "tenant_id") + delete(updates, "version") + delete(updates, "source") + delete(updates, "created_by") + + current, err := store.GetByID(ctx, id) + if err != nil || current == nil { + return mcpgo.NewToolResultError("hooks.update: hook not found: " + hookIDStr), nil + } + merged := applyMCPHookPatch(*current, updates) + if err := merged.Validate(edition.Current(), true); err != nil { + return toolError("hooks.update", err) + } + + if err := store.Update(ctx, id, updates); err != nil { + if errors.Is(err, hooks.ErrBuiltinReadOnly) { + return mcpgo.NewToolResultError("hooks.update: builtin hooks are read-only"), nil + } + return toolError("hooks.update", err) + } + return jsonToolResult(map[string]string{"hookId": id.String()}) + } +} + +func handleHooksDelete(store hooks.HookStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + hookIDStr, err := req.RequireString("hook_id") + if err != nil { + return toolError("hooks.delete", err) + } + id, err := uuid.Parse(hookIDStr) + if err != nil { + return toolError("hooks.delete", fmt.Errorf("invalid hook_id: %w", err)) + } + if err := store.Delete(ctx, id); err != nil { + if errors.Is(err, hooks.ErrBuiltinReadOnly) { + return mcpgo.NewToolResultError("hooks.delete: builtin hooks are read-only"), nil + } + return toolError("hooks.delete", err) + } + return jsonToolResult(map[string]string{"hookId": id.String()}) + } +} + +func handleHooksToggle(store hooks.HookStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + hookIDStr, err := req.RequireString("hook_id") + if err != nil { + return toolError("hooks.toggle", err) + } + id, err := uuid.Parse(hookIDStr) + if err != nil { + return toolError("hooks.toggle", fmt.Errorf("invalid hook_id: %w", err)) + } + enabled, err := req.RequireBool("enabled") + if err != nil { + return toolError("hooks.toggle", err) + } + if err := store.Update(ctx, id, map[string]any{"enabled": enabled}); err != nil { + return toolError("hooks.toggle", err) + } + return jsonToolResult(map[string]any{"hookId": id.String(), "enabled": enabled}) + } +} + +func handleHooksTest() mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultError("hooks.test: dry-run test runner is not available on this MCP surface"), nil + } +} + +func handleHooksHistory() mcpserver.ToolHandlerFunc { + return func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(map[string]any{ + "executions": []any{}, + "nextCursor": "", + "note": "history pagination is not yet implemented in HookStore", + }) + } +} diff --git a/internal/mcp/crud_hooks_test.go b/internal/mcp/crud_hooks_test.go new file mode 100644 index 00000000..ee9bb68d --- /dev/null +++ b/internal/mcp/crud_hooks_test.go @@ -0,0 +1,97 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" +) + +func TestHooksCreate_HappyPath(t *testing.T) { + store := newFakeHookStore() + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + result := callTool(t, srv, "goclaw_hooks_create", map[string]any{ + "config": map[string]any{ + "handler_type": "http", + "event": "pre_tool_use", + "scope": "global", + "config": map[string]any{"url": "https://example.com"}, + }, + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Len(t, store.created, 1) +} + +func TestHooksCreate_MissingRequiredFields(t *testing.T) { + store := newFakeHookStore() + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + result := callTool(t, srv, "goclaw_hooks_create", map[string]any{ + "config": map[string]any{"handler_type": "http"}, + }) + assert.True(t, toolIsError(result)) +} + +func TestHooksList_FiltersByEnabled(t *testing.T) { + store := newFakeHookStore() + store.created[uuid.New()] = hooks.HookConfig{Enabled: true} + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + result := callTool(t, srv, "goclaw_hooks_list", map[string]any{}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "hooks") +} + +func TestHooksToggle_And_Delete(t *testing.T) { + store := newFakeHookStore() + id, err := store.Create(context.Background(), hooks.HookConfig{HandlerType: "http", Event: "pre_tool_use", Scope: "global"}) + require.NoError(t, err) + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + toggled := callTool(t, srv, "goclaw_hooks_toggle", map[string]any{"hook_id": id.String(), "enabled": true}) + require.False(t, toolIsError(toggled)) + + deleted := callTool(t, srv, "goclaw_hooks_delete", map[string]any{"hook_id": id.String()}) + require.False(t, toolIsError(deleted)) + + deleteAgain := callTool(t, srv, "goclaw_hooks_delete", map[string]any{"hook_id": id.String()}) + assert.True(t, toolIsError(deleteAgain)) +} + +func TestHooksUpdate_NotFound(t *testing.T) { + store := newFakeHookStore() + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + result := callTool(t, srv, "goclaw_hooks_update", map[string]any{ + "hook_id": uuid.New().String(), + "updates": map[string]any{"enabled": false}, + }) + assert.True(t, toolIsError(result)) +} + +// TestHooksTest_And_History_AreDocumentedStubs guards the two +// intentionally-unimplemented tools on this MCP surface against accidental +// silent regression. +func TestHooksTest_And_History_AreDocumentedStubs(t *testing.T) { + store := newFakeHookStore() + srv := newTestMCPServer() + registerHooksCRUDTools(srv, store) + + testResult := callTool(t, srv, "goclaw_hooks_test", map[string]any{"config": map[string]any{}}) + assert.True(t, toolIsError(testResult)) + assert.Contains(t, toolResultText(testResult), "not available") + + historyResult := callTool(t, srv, "goclaw_hooks_history", map[string]any{}) + require.False(t, toolIsError(historyResult)) + assert.Contains(t, toolResultText(historyResult), "not yet implemented") +} diff --git a/internal/mcp/crud_llm.go b/internal/mcp/crud_llm.go new file mode 100644 index 00000000..ec7e4bbb --- /dev/null +++ b/internal/mcp/crud_llm.go @@ -0,0 +1,148 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// LLMDefaults carries the gateway's background-provider fallback used when a +// goclaw_llm_complete call does not specify a provider/model, mirroring +// internal/gateway/methods/llm.go's llmDefaults. +type LLMDefaults struct { + Provider string + Model string +} + +// registerLLMCRUDTool registers goclaw_llm_complete, backed by the same +// providers.Registry the gateway's own llm.complete WS method uses. The WS +// method additionally requires RoleOperator; this MCP surface has no +// per-caller role (the bearer token is the sole boundary), matching the rest +// of this CRUD MCP server. +func registerLLMCRUDTool(srv *mcpserver.MCPServer, reg *providers.Registry, defaults LLMDefaults) { + srv.AddTool(mcpgo.NewTool("goclaw_llm_complete", + mcpgo.WithDescription("Request a one-shot LLM completion via the gateway's configured provider registry, bypassing the agent loop."), + mcpgo.WithString("provider", mcpgo.Description("Provider name (e.g. \"anthropic\"); defaults to the gateway's background provider.")), + mcpgo.WithString("model", mcpgo.Description("Model name; defaults to the gateway's background model or the provider's default.")), + mcpgo.WithArray("messages", mcpgo.Required(), mcpgo.Description("Chat messages: [{role, content}, ...].")), + mcpgo.WithNumber("temperature", mcpgo.Description("Sampling temperature.")), + mcpgo.WithNumber("max_tokens", mcpgo.Description("Max completion tokens.")), + ), handleLLMComplete(reg, defaults)) +} + +type llmCompleteMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func handleLLMComplete(reg *providers.Registry, defaults LLMDefaults) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if reg == nil { + return mcpgo.NewToolResultError("llm.complete: no providers configured"), nil + } + + args := req.GetArguments() + rawMessages, _ := args["messages"].([]any) + if len(rawMessages) == 0 { + return mcpgo.NewToolResultError("llm.complete: messages is required"), nil + } + messages := make([]providers.Message, 0, len(rawMessages)) + for i, raw := range rawMessages { + obj, ok := raw.(map[string]any) + if !ok { + return mcpgo.NewToolResultError(fmt.Sprintf("llm.complete: messages[%d] must be an object", i)), nil + } + role, _ := obj["role"].(string) + content, _ := obj["content"].(string) + if strings.TrimSpace(role) == "" { + return mcpgo.NewToolResultError(fmt.Sprintf("llm.complete: messages[%d].role is required", i)), nil + } + if strings.TrimSpace(content) == "" { + return mcpgo.NewToolResultError(fmt.Sprintf("llm.complete: messages[%d].content is required", i)), nil + } + messages = append(messages, providers.Message{Role: role, Content: content}) + } + + providerName := strings.TrimSpace(req.GetString("provider", "")) + if providerName == "" { + providerName = strings.TrimSpace(defaults.Provider) + } + prov, model, err := resolveLLMProvider(ctx, reg, defaults, providerName, strings.TrimSpace(req.GetString("model", ""))) + if err != nil { + return toolError("llm.complete", err) + } + + options := map[string]any{} + if maxTokens := int(req.GetFloat("max_tokens", 0)); maxTokens > 0 { + options[providers.OptMaxTokens] = maxTokens + } + if temp, ok := args["temperature"].(float64); ok { + options[providers.OptTemperature] = temp + } + + resp, err := prov.Chat(ctx, providers.ChatRequest{ + Messages: messages, + Model: model, + Options: options, + }) + if err != nil { + return toolError("llm.complete", err) + } + + result := map[string]any{ + "provider": prov.Name(), + "model": model, + "content": resp.Content, + } + if resp.Usage != nil { + result["usage"] = resp.Usage + } + return jsonToolResult(result) + } +} + +func resolveLLMProvider(ctx context.Context, reg *providers.Registry, defaults LLMDefaults, providerName, model string) (providers.Provider, string, error) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = providers.MasterTenantID + } + + try := func(name string) (providers.Provider, string, bool) { + if name == "" { + return nil, "", false + } + p, err := reg.GetForTenant(tenantID, name) + if err != nil || p == nil { + return nil, "", false + } + selectedModel := model + if selectedModel == "" { + selectedModel = strings.TrimSpace(defaults.Model) + } + if selectedModel == "" { + selectedModel = p.DefaultModel() + } + return p, selectedModel, true + } + + if p, selectedModel, ok := try(providerName); ok { + return p, selectedModel, nil + } + if providerName != "" { + return nil, "", fmt.Errorf("provider not found: %s", providerName) + } + for _, name := range reg.ListForTenant(tenantID) { + if p, selectedModel, ok := try(name); ok { + return p, selectedModel, nil + } + } + return nil, "", fmt.Errorf("no providers configured") +} diff --git a/internal/mcp/crud_llm_test.go b/internal/mcp/crud_llm_test.go new file mode 100644 index 00000000..0c8b86ee --- /dev/null +++ b/internal/mcp/crud_llm_test.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// fakeProvider is a minimal providers.Provider for goclaw_llm_complete tests. +type fakeProvider struct { + name string + model string + reply string + chatErr error +} + +func (p *fakeProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) { + if p.chatErr != nil { + return nil, p.chatErr + } + return &providers.ChatResponse{Content: p.reply}, nil +} + +func (p *fakeProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) { + return &providers.ChatResponse{Content: p.reply}, nil +} + +func (p *fakeProvider) DefaultModel() string { return p.model } +func (p *fakeProvider) Name() string { return p.name } + +func newLLMRegistry(prov *fakeProvider) *providers.Registry { + reg := providers.NewRegistry(store.TenantIDFromContext) + reg.Register(prov) + return reg +} + +func TestLLMComplete_HappyPath(t *testing.T) { + prov := &fakeProvider{name: "anthropic", model: "claude", reply: "42"} + reg := newLLMRegistry(prov) + srv := newTestMCPServer() + registerLLMCRUDTool(srv, reg, LLMDefaults{}) + + result := callTool(t, srv, "goclaw_llm_complete", map[string]any{ + "messages": []any{ + map[string]any{"role": "user", "content": "what is 6*7?"}, + }, + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Contains(t, toolResultText(result), "42") +} + +func TestLLMComplete_RequiresMessages(t *testing.T) { + prov := &fakeProvider{name: "anthropic", model: "claude"} + reg := newLLMRegistry(prov) + srv := newTestMCPServer() + registerLLMCRUDTool(srv, reg, LLMDefaults{}) + + result := callTool(t, srv, "goclaw_llm_complete", map[string]any{"messages": []any{}}) + assert.True(t, toolIsError(result)) +} + +func TestLLMComplete_UnknownProvider(t *testing.T) { + prov := &fakeProvider{name: "anthropic", model: "claude"} + reg := newLLMRegistry(prov) + srv := newTestMCPServer() + registerLLMCRUDTool(srv, reg, LLMDefaults{}) + + result := callTool(t, srv, "goclaw_llm_complete", map[string]any{ + "provider": "does-not-exist", + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + }) + assert.True(t, toolIsError(result)) +} + +func TestLLMComplete_NilRegistry(t *testing.T) { + srv := newTestMCPServer() + registerLLMCRUDTool(srv, nil, LLMDefaults{}) + + result := callTool(t, srv, "goclaw_llm_complete", map[string]any{ + "messages": []any{map[string]any{"role": "user", "content": "hi"}}, + }) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "no providers configured") +} diff --git a/internal/mcp/crud_logs.go b/internal/mcp/crud_logs.go new file mode 100644 index 00000000..18ca8289 --- /dev/null +++ b/internal/mcp/crud_logs.go @@ -0,0 +1,77 @@ +package mcp + +import ( + "context" + "time" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + runtimelogs "github.com/nextlevelbuilder/goclaw/internal/logs" +) + +// RuntimeLogSnapshotter returns a bounded, in-memory aggregate of recent +// runtime log entries. Implemented by *gateway.LogTee (see +// internal/gateway/log_tee.go's AggregateRuntimeLogs), which already backs +// the HTTP GET /v1/logs/runtime/aggregate endpoint +// (internal/http/logs.go) — reused here via a narrow interface so this +// package does not need to import internal/gateway (which itself imports +// this package, see crud_server.go's package doc comment for the cycle +// rationale shared across this file's siblings). +type RuntimeLogSnapshotter interface { + AggregateRuntimeLogs(opts runtimelogs.RuntimeAggregateOpts) runtimelogs.RuntimeAggregateResult +} + +// registerLogsCRUDTool registers goclaw_logs_tail. Unlike the WS logs.tail +// RPC method — which starts/stops a live push subscription over the +// connection's own WebSocket — this MCP surface is a stateless HTTP server +// (mcpserver.WithStateLess(true), see crud_server.go) with no persistent +// per-caller connection to push log lines to. There is therefore no way to +// honor the "start tailing, then receive server-pushed log events" contract +// here. Instead this tool returns a one-shot aggregate snapshot of the +// gateway's bounded runtime log ring buffer (the same data backing +// GET /v1/logs/runtime/aggregate), which is the closest real, queryable +// equivalent available to a stateless caller. The "action" param is accepted +// for naming parity with the WS method but only "start" (or empty) produces +// a snapshot; "stop" is a no-op success (nothing was subscribed). +func registerLogsCRUDTool(srv *mcpserver.MCPServer, snapshotter RuntimeLogSnapshotter) { + srv.AddTool(mcpgo.NewTool("goclaw_logs_tail", + mcpgo.WithDescription("Return a snapshot aggregate of recent runtime log entries (grouped by level or source). This is a point-in-time read, not a live push subscription: MCP tool calls are stateless request/response, so there is no channel to stream log lines to the caller as they occur."), + mcpgo.WithString("action", mcpgo.Description("\"start\" (default) returns a snapshot; \"stop\" is a no-op success.")), + mcpgo.WithString("group_by", mcpgo.Description("\"level\" (default) or \"source\".")), + mcpgo.WithString("level", mcpgo.Description("Filter to a single level (\"debug\", \"info\", \"warn\", \"error\").")), + mcpgo.WithString("source", mcpgo.Description("Filter to a single log source/component.")), + mcpgo.WithString("since", mcpgo.Description("RFC3339 timestamp; only entries at or after this time are included.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleLogsTail(snapshotter)) +} + +func handleLogsTail(snapshotter RuntimeLogSnapshotter) mcpserver.ToolHandlerFunc { + return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + action := req.GetString("action", "start") + if action == "stop" { + return jsonToolResult(map[string]any{"status": "stopped"}) + } + if snapshotter == nil { + return mcpgo.NewToolResultError("logs.tail: runtime log snapshotter not available"), nil + } + + groupBy := req.GetString("group_by", "level") + var fromMS int64 + if since := req.GetString("since", ""); since != "" { + t, err := time.Parse(time.RFC3339, since) + if err != nil { + return toolError("logs.tail", err) + } + fromMS = t.UnixMilli() + } + + result := snapshotter.AggregateRuntimeLogs(runtimelogs.RuntimeAggregateOpts{ + GroupBy: groupBy, + Level: req.GetString("level", ""), + Source: req.GetString("source", ""), + FromMS: fromMS, + }) + return jsonToolResult(map[string]any{"status": "ok", "aggregate": result}) + } +} diff --git a/internal/mcp/crud_pairing.go b/internal/mcp/crud_pairing.go new file mode 100644 index 00000000..a6308461 --- /dev/null +++ b/internal/mcp/crud_pairing.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "log/slog" + "regexp" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// validMCPSenderIDRe mirrors internal/gateway/methods/pairing.go's +// validSenderIDRe — safe characters only, prevents log injection. +var validMCPSenderIDRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:@-]*$`) + +const maxSenderIDLen = 128 + +// isValidMCPSenderID mirrors internal/gateway/methods/pairing.go's +// isValidSenderID helper. +func isValidMCPSenderID(id string) bool { + return len(id) <= maxSenderIDLen && validMCPSenderIDRe.MatchString(id) +} + +// registerPairingCRUDTools registers the goclaw_pairing_device_* and +// goclaw_pairing_browser_status MCP tools backed by store.PairingStore. +// Mirrors internal/gateway/methods/pairing.go minus the approve-callback +// (channel notification) and event-broadcast side effects, which are WS/bus +// concerns not applicable to this standalone MCP surface. +func registerPairingCRUDTools(srv *mcpserver.MCPServer, pairing store.PairingStore) { + srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_request", + mcpgo.WithDescription("Request a device pairing code."), + mcpgo.WithString("sender_id", mcpgo.Required(), mcpgo.Description("Sender identifier.")), + mcpgo.WithString("channel", mcpgo.Required(), mcpgo.Description("Channel name.")), + mcpgo.WithString("chat_id", mcpgo.Description("Chat ID.")), + mcpgo.WithString("account_id", mcpgo.Description("Account ID; defaults to \"default\".")), + ), handlePairingDeviceRequest(pairing)) + + srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_approve", + mcpgo.WithDescription("Approve a pending pairing code."), + mcpgo.WithString("code", mcpgo.Required(), mcpgo.Description("Pairing code.")), + mcpgo.WithString("approved_by", mcpgo.Description("Approver identifier; defaults to \"operator\".")), + ), handlePairingDeviceApprove(pairing)) + + srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_deny", + mcpgo.WithDescription("Deny a pending pairing code."), + mcpgo.WithString("code", mcpgo.Required(), mcpgo.Description("Pairing code.")), + ), handlePairingDeviceDeny(pairing)) + + srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_list", + mcpgo.WithDescription("List pending and paired devices."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handlePairingDeviceList(pairing)) + + srv.AddTool(mcpgo.NewTool("goclaw_pairing_device_revoke", + mcpgo.WithDescription("Revoke an approved device pairing."), + mcpgo.WithString("sender_id", mcpgo.Required(), mcpgo.Description("Sender identifier.")), + mcpgo.WithString("channel", mcpgo.Required(), mcpgo.Description("Channel name.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handlePairingDeviceRevoke(pairing)) + + srv.AddTool(mcpgo.NewTool("goclaw_pairing_browser_status", + mcpgo.WithDescription("Check the pairing status for a pending browser client."), + mcpgo.WithString("sender_id", mcpgo.Required(), mcpgo.Description("Sender identifier.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handlePairingBrowserStatus(pairing)) +} + +func handlePairingDeviceRequest(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + senderID, err := req.RequireString("sender_id") + if err != nil { + return toolError("pairing.request", err) + } + channel, err := req.RequireString("channel") + if err != nil { + return toolError("pairing.request", err) + } + if !isValidMCPSenderID(senderID) { + slog.Warn("security.invalid_sender_id_format", "handler", "mcp.pairing.request") + return mcpgo.NewToolResultError("pairing.request: invalid sender_id format"), nil + } + accountID := req.GetString("account_id", "default") + code, err := pairing.RequestPairing(ctx, senderID, channel, req.GetString("chat_id", ""), accountID, nil) + if err != nil { + return toolError("pairing.request", err) + } + return jsonToolResult(map[string]string{"code": code}) + } +} + +func handlePairingDeviceApprove(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return toolError("pairing.approve", err) + } + approvedBy := req.GetString("approved_by", "operator") + paired, err := pairing.ApprovePairing(ctx, code, approvedBy) + if err != nil { + return toolError("pairing.approve", err) + } + return jsonToolResult(map[string]any{"paired": paired}) + } +} + +func handlePairingDeviceDeny(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + code, err := req.RequireString("code") + if err != nil { + return toolError("pairing.deny", err) + } + if err := pairing.DenyPairing(ctx, code); err != nil { + return toolError("pairing.deny", err) + } + return jsonToolResult(map[string]bool{"denied": true}) + } +} + +func handlePairingDeviceList(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return jsonToolResult(map[string]any{ + "pending": pairing.ListPending(ctx), + "paired": pairing.ListPaired(ctx), + }) + } +} + +func handlePairingDeviceRevoke(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + senderID, err := req.RequireString("sender_id") + if err != nil { + return toolError("pairing.revoke", err) + } + channel, err := req.RequireString("channel") + if err != nil { + return toolError("pairing.revoke", err) + } + if !isValidMCPSenderID(senderID) { + slog.Warn("security.invalid_sender_id_format", "handler", "mcp.pairing.revoke") + return mcpgo.NewToolResultError("pairing.revoke: invalid sender_id format"), nil + } + if err := pairing.RevokePairing(ctx, senderID, channel); err != nil { + return toolError("pairing.revoke", err) + } + return jsonToolResult(map[string]bool{"revoked": true}) + } +} + +func handlePairingBrowserStatus(pairing store.PairingStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + senderID, err := req.RequireString("sender_id") + if err != nil { + return toolError("pairing.browser.status", err) + } + if !isValidMCPSenderID(senderID) { + slog.Warn("security.invalid_sender_id_format", "handler", "mcp.pairing.browser_status") + return mcpgo.NewToolResultError("pairing.browser.status: invalid sender_id format"), nil + } + paired, pairErr := pairing.IsPaired(ctx, senderID, "browser") + if pairErr != nil { + slog.Warn("security.pairing_check_failed", "error", pairErr) + } + if paired { + return jsonToolResult(map[string]string{"status": "approved"}) + } + for _, p := range pairing.ListPending(ctx) { + if p.SenderID == senderID && p.Channel == "browser" { + return jsonToolResult(map[string]string{"status": "pending"}) + } + } + return jsonToolResult(map[string]string{"status": "expired"}) + } +} diff --git a/internal/mcp/crud_pairing_test.go b/internal/mcp/crud_pairing_test.go new file mode 100644 index 00000000..ef38a288 --- /dev/null +++ b/internal/mcp/crud_pairing_test.go @@ -0,0 +1,65 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPairingDeviceRequest_HappyAndInvalidSenderID(t *testing.T) { + pairing := newFakePairingStore() + srv := newTestMCPServer() + registerPairingCRUDTools(srv, pairing) + + result := callTool(t, srv, "goclaw_pairing_device_request", map[string]any{ + "sender_id": "user-123", "channel": "telegram", + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Contains(t, toolResultText(result), "code") + + invalid := callTool(t, srv, "goclaw_pairing_device_request", map[string]any{ + "sender_id": "; rm -rf /", "channel": "telegram", + }) + assert.True(t, toolIsError(invalid)) + assert.Contains(t, toolResultText(invalid), "invalid sender_id format") +} + +func TestPairingDeviceApproveDenyList(t *testing.T) { + pairing := newFakePairingStore() + srv := newTestMCPServer() + registerPairingCRUDTools(srv, pairing) + + req := callTool(t, srv, "goclaw_pairing_device_request", map[string]any{"sender_id": "user-1", "channel": "telegram"}) + require.False(t, toolIsError(req)) + + approved := callTool(t, srv, "goclaw_pairing_device_approve", map[string]any{"code": "CODE123"}) + require.False(t, toolIsError(approved)) + + list := callTool(t, srv, "goclaw_pairing_device_list", map[string]any{}) + require.False(t, toolIsError(list)) + assert.Contains(t, toolResultText(list), "user-1") + + deny := callTool(t, srv, "goclaw_pairing_device_deny", map[string]any{"code": "no-such-code"}) + assert.True(t, toolIsError(deny)) +} + +func TestPairingDeviceRevoke(t *testing.T) { + pairing := newFakePairingStore() + srv := newTestMCPServer() + registerPairingCRUDTools(srv, pairing) + + // Not paired: revoke should fail. + result := callTool(t, srv, "goclaw_pairing_device_revoke", map[string]any{"sender_id": "user-9", "channel": "telegram"}) + assert.True(t, toolIsError(result)) +} + +func TestPairingBrowserStatus_Expired(t *testing.T) { + pairing := newFakePairingStore() + srv := newTestMCPServer() + registerPairingCRUDTools(srv, pairing) + + result := callTool(t, srv, "goclaw_pairing_browser_status", map[string]any{"sender_id": "user-1"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "expired") +} diff --git a/internal/mcp/crud_run_timeline.go b/internal/mcp/crud_run_timeline.go new file mode 100644 index 00000000..d1e719ea --- /dev/null +++ b/internal/mcp/crud_run_timeline.go @@ -0,0 +1,45 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerRunTimelineCRUDTools registers the goclaw_run_timeline_get MCP tool +// backed by store.RunTimelineStore. +func registerRunTimelineCRUDTools(srv *mcpserver.MCPServer, timeline store.RunTimelineStore) { + srv.AddTool(mcpgo.NewTool("goclaw_run_timeline_get", + mcpgo.WithDescription("Fetch the archived timeline for a run or session."), + mcpgo.WithString("run_id", mcpgo.Description("Run ID; preferred when known.")), + mcpgo.WithString("session_key", mcpgo.Description("Session key; used to find the latest run when run_id is not known.")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum items to return.")), + mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleRunTimelineGet(timeline)) +} + +func handleRunTimelineGet(timeline store.RunTimelineStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + opts := store.RunTimelineListOpts{ + RunID: req.GetString("run_id", ""), + SessionKey: req.GetString("session_key", ""), + Limit: int(req.GetFloat("limit", 0)), + Offset: int(req.GetFloat("offset", 0)), + } + items, err := timeline.ListRunTimelineItems(ctx, opts) + if err != nil { + return toolError("run_timeline.get", err) + } + return jsonToolResult(map[string]any{ + "runId": opts.RunID, + "sessionKey": opts.SessionKey, + "items": items, + "limit": opts.Limit, + "offset": opts.Offset, + }) + } +} diff --git a/internal/mcp/crud_send.go b/internal/mcp/crud_send.go new file mode 100644 index 00000000..44f0c2d8 --- /dev/null +++ b/internal/mcp/crud_send.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// registerSendCRUDTool registers goclaw_send, backed by the same +// bus.MessageBus outbound publish path used by the gateway's own "send" WS +// RPC method (internal/gateway/methods/send.go). +func registerSendCRUDTool(srv *mcpserver.MCPServer, msgBus *bus.MessageBus) { + srv.AddTool(mcpgo.NewTool("goclaw_send", + mcpgo.WithDescription("Route an outbound message to a channel."), + mcpgo.WithString("channel", mcpgo.Required(), mcpgo.Description("Channel instance name.")), + mcpgo.WithString("to", mcpgo.Required(), mcpgo.Description("Destination chat/peer ID on that channel.")), + mcpgo.WithString("message", mcpgo.Required(), mcpgo.Description("Message text to send.")), + ), handleSendCRUD(msgBus)) +} + +func handleSendCRUD(msgBus *bus.MessageBus) mcpserver.ToolHandlerFunc { + return func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if msgBus == nil { + return mcpgo.NewToolResultError("send: message bus not available"), nil + } + channel, err := req.RequireString("channel") + if err != nil { + return toolError("send", err) + } + to, err := req.RequireString("to") + if err != nil { + return toolError("send", err) + } + message, err := req.RequireString("message") + if err != nil { + return toolError("send", err) + } + + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: to, + Content: message, + }) + + return jsonToolResult(map[string]any{ + "ok": true, + "channel": channel, + "to": to, + }) + } +} diff --git a/internal/mcp/crud_server.go b/internal/mcp/crud_server.go new file mode 100644 index 00000000..71ccf72e --- /dev/null +++ b/internal/mcp/crud_server.go @@ -0,0 +1,231 @@ +// Package mcp exposes goclaw's Model Context Protocol bridge/server surface. +// crud_server.go implements a second, distinct MCP server (separate from the +// tool bridge in bridge_server.go) that exposes goclaw's CRUD-style resource +// management surface — agents, sessions, skills, cron, config, agent links, +// API keys, config permissions, Bitrix24 portals, run timelines, teams, +// teams tasks, teams workspace, channels, channel instances, hooks, +// heartbeat, pairing, exec approval, usage, quota, chat/chat-behavior, LLM +// completion, runtime logs, outbound send, and TTS voices — as MCP tools +// backed directly by the real store/subsystem implementations used by the +// gateway's own WebSocket RPC methods. +// +// Tenant scope: the CRUD MCP server is gated by a single shared bearer +// secret (gateway.mcp_server_token) with no per-caller identity, so it is +// treated like the gateway-token/owner path in internal/http/auth.go — +// callers may optionally scope a request to a tenant via the +// "X-GoClaw-Tenant-Id" header (UUID or slug), with no membership check +// (the token itself is the full-trust boundary), falling back to +// store.MasterTenantID when the header is absent or unresolvable. This is +// applied once per request via mcpserver.WithHTTPContextFunc in +// NewCRUDServer, so every tool handler in this package can rely on +// store.TenantIDFromContext(ctx) already carrying a concrete value. +package mcp + +import ( + "context" + "database/sql" + "log/slog" + "net/http" + + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/hooks" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// CRUDDeps bundles the store dependencies the CRUD MCP server needs. +// All fields are optional — tool groups whose backing store is nil are +// simply not registered, so this server degrades gracefully across editions +// (e.g. SQLite/lite builds that omit certain stores). +type CRUDDeps struct { + Agents store.AgentStore + AgentRuntime AgentRuntimeLookup // enables goclaw_agent_{get,wait}; agent identity/files work without it + Sessions store.SessionStore + Skills store.SkillStore + Cron store.CronStore + Config *config.Config + AgentLinks store.AgentLinkStore + APIKeys store.APIKeyStore + ConfigPermissions store.ConfigPermissionStore + Bitrix store.BitrixPortalStore + RunTimeline store.RunTimelineStore + + // Phase 2: subsystem-backed tool families. + Teams store.TeamStore + ChannelInstances store.ChannelInstanceStore + ChannelManager *channels.Manager + Hooks hooks.HookStore + Heartbeats store.HeartbeatStore + Providers store.ProviderStore + Pairing store.PairingStore + ExecApproval *tools.ExecApprovalManager + Quota *channels.QuotaChecker + DB *sql.DB // for quota.usage today's trace summary + + // Phase 3: live-runtime-backed tool families (chat, LLM, logs, send, voices). + ChatRunner ChatRunner // enables goclaw_chat_{send,abort,session_status} + LLMProviders *providers.Registry // enables goclaw_llm_complete + LLMDefaults LLMDefaults // background provider/model fallback for goclaw_llm_complete + MessageBus *bus.MessageBus // enables goclaw_send + RuntimeLogs RuntimeLogSnapshotter // enables goclaw_logs_tail + VoiceCache *audio.VoiceCache // enables goclaw_voices_{list,refresh} + VoiceSecretsStore store.ConfigSecretsStore // per-tenant TTS provider API key lookup for goclaw_voices_* + + // Tenants resolves the optional "X-GoClaw-Tenant-Id" request header (UUID + // or slug) to a concrete tenant for every CRUD MCP call — see + // resolveMCPTenantID. Every tenant-scoped tool handler in this package + // relies on store.TenantIDFromContext(ctx) already carrying a resolved + // value by the time it runs. When nil, all requests are treated as + // store.MasterTenantID (same fail-safe default resolveMCPTenantID uses + // when the header is absent/unresolvable). + Tenants store.TenantStore +} + +// NewCRUDServer builds a StreamableHTTPServer exposing goclaw's CRUD +// resources (agents, sessions, skills, cron, config, agent links, API keys, +// config permissions, Bitrix24 portals, run timelines) as MCP tools. Callers +// are expected to gate access to the returned handler with a bearer-token +// middleware (see gateway.tokenAuthMiddleware / Server.BuildMux) before +// mounting it — this server performs no authentication of its own. +func NewCRUDServer(deps CRUDDeps, version string) *mcpserver.StreamableHTTPServer { + srv := mcpserver.NewMCPServer("goclaw-crud", version, + mcpserver.WithToolCapabilities(false), + ) + + var registered int + if deps.Agents != nil { + registerAgentCRUDTools(srv, deps.Agents) + registered += 5 + } + if deps.Agents != nil && deps.AgentRuntime != nil { + registerAgentRuntimeCRUDTools(srv, deps.Agents, deps.AgentRuntime) + registered += 5 + } + if deps.Sessions != nil { + registerSessionCRUDTools(srv, deps.Sessions) + registered += 7 + } + if deps.Skills != nil { + registerSkillCRUDTools(srv, deps.Skills) + registered += 2 + if manage, ok := deps.Skills.(store.SkillManageStore); ok { + registerSkillUpdateCRUDTool(srv, deps.Skills, manage) + registered++ + if deps.Config != nil { + registerSkillWriteFileCRUDTool(srv, deps.Skills, manage, deps.Config) + registered++ + } + } + } + if deps.Cron != nil { + registerCronCRUDTools(srv, deps.Cron) + registered += 8 + } + if deps.Config != nil { + registerConfigCRUDTools(srv, deps.Config) + registered++ + } + if deps.AgentLinks != nil { + registerAgentLinkCRUDTools(srv, deps.AgentLinks) + registered += 4 + } + if deps.APIKeys != nil { + registerAPIKeyCRUDTools(srv, deps.APIKeys) + registered += 3 + } + if deps.ConfigPermissions != nil { + registerConfigPermissionCRUDTools(srv, deps.ConfigPermissions) + registered += 4 + } + if deps.Bitrix != nil { + registerBitrixCRUDTools(srv, deps.Bitrix) + registered += 3 + } + if deps.RunTimeline != nil { + registerRunTimelineCRUDTools(srv, deps.RunTimeline) + registered++ + } + if deps.Teams != nil { + registerTeamsCRUDTools(srv, deps.Teams, deps.Agents) + registered += 10 + registerTeamsTasksCRUDTools(srv, deps.Teams, deps.Agents) + registered += 13 + if deps.Config != nil { + registerTeamsWorkspaceCRUDTools(srv, deps.Teams, deps.Config) + registered += 3 + } + } + if deps.ChannelManager != nil { + registerChannelsCRUDTools(srv, deps.ChannelManager) + registered += 3 + } + if deps.ChannelInstances != nil { + registerChannelInstancesCRUDTools(srv, deps.ChannelInstances, deps.Agents) + registered += 5 + } + if deps.Hooks != nil { + registerHooksCRUDTools(srv, deps.Hooks) + registered += 7 + } + if deps.Heartbeats != nil { + registerHeartbeatCRUDTools(srv, deps.Heartbeats, deps.Agents, deps.Providers) + registered += 8 + } + if deps.Pairing != nil { + registerPairingCRUDTools(srv, deps.Pairing) + registered += 6 + } + if deps.ExecApproval != nil { + registerExecApprovalCRUDTools(srv, deps.ExecApproval) + registered += 3 + } + if deps.Sessions != nil { + registerUsageCRUDTools(srv, deps.Sessions) + registered += 2 + } + registerQuotaCRUDTools(srv, deps.Quota, deps.DB) + registered++ + + // Phase 3: live-runtime-backed tool families. + if deps.ChatRunner != nil && deps.Sessions != nil { + registerChatCRUDTools(srv, deps.ChatRunner, deps.Sessions) + registered += 5 + } + if deps.Config != nil { + registerChatBehaviorCRUDTool(srv, deps.Config, deps.ChannelManager) + registered++ + } + if deps.LLMProviders != nil { + registerLLMCRUDTool(srv, deps.LLMProviders, deps.LLMDefaults) + registered++ + } + if deps.RuntimeLogs != nil { + registerLogsCRUDTool(srv, deps.RuntimeLogs) + registered++ + } + if deps.MessageBus != nil { + registerSendCRUDTool(srv, deps.MessageBus) + registered++ + } + if deps.VoiceCache != nil { + registerVoicesCRUDTools(srv, deps.VoiceCache, deps.VoiceSecretsStore) + registered += 2 + } + + slog.Info("mcp.crud: tools registered", "count", registered) + + tenants := deps.Tenants + return mcpserver.NewStreamableHTTPServer(srv, + mcpserver.WithStateLess(true), + mcpserver.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { + tenantID := resolveMCPTenantID(ctx, tenants, r.Header.Get(mcpTenantHeader)) + return store.WithTenantID(ctx, tenantID) + }), + ) +} diff --git a/internal/mcp/crud_server_test.go b/internal/mcp/crud_server_test.go new file mode 100644 index 00000000..a128cc52 --- /dev/null +++ b/internal/mcp/crud_server_test.go @@ -0,0 +1,149 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// TestNewCRUDServer_NoDeps_RegistersOnlyQuota verifies the "degrade +// gracefully" contract documented on CRUDDeps: with a completely empty +// CRUDDeps, no store-backed tool family is registered — only the +// unconditional goclaw_quota_* family (which tolerates nil Quota/DB), so a +// deployment with nothing wired still produces a server that constructs +// without panicking and exposes no dangling/broken tools. +func TestNewCRUDServer_NoDeps_RegistersOnlyQuota(t *testing.T) { + httpServer := NewCRUDServer(CRUDDeps{}, "test") + if httpServer == nil { + t.Fatal("expected non-nil StreamableHTTPServer even with empty deps") + } +} + +// TestNewCRUDServer_AgentsOnly_RegistersAgentToolsNotSessions verifies that +// each tool family is gated strictly on its own dependency being non-nil, +// independent of other families. +func TestNewCRUDServer_AgentsOnly_RegistersAgentToolsNotSessions(t *testing.T) { + agents := newFakeAgentStore() + + // Build the same MCPServer construction NewCRUDServer uses internally by + // calling the registration function directly, since CRUDDeps only + // exposes the fully-assembled StreamableHTTPServer (no tool introspection + // hook) — mirror its gating logic against a bare MCPServer instead. + srv := newTestMCPServer() + registerAgentCRUDTools(srv, agents) + + if tool := srv.GetTool("goclaw_agents_list"); tool == nil { + t.Error("expected goclaw_agents_list to be registered when Agents is set") + } + if tool := srv.GetTool("goclaw_sessions_list"); tool != nil { + t.Error("expected goclaw_sessions_list to NOT be registered when Sessions was never registered") + } +} + +// TestNewCRUDServer_SkillManageStore_RegistersUpdateToolOnlyWhenSupported +// verifies goclaw_skills_update is only registered when the skill store also +// implements store.SkillManageStore (crud_server.go's type-assertion gate). +func TestNewCRUDServer_SkillManageStore_RegistersUpdateToolOnlyWhenSupported(t *testing.T) { + // Plain SkillStore (no manage capability): registerSkillCRUDTools alone, + // mirroring what NewCRUDServer does when the type assertion fails. + srv := newTestMCPServer() + skills := newFakeSkillStore() + registerSkillCRUDTools(srv, skills) + if tool := srv.GetTool("goclaw_skills_update"); tool != nil { + t.Error("expected goclaw_skills_update to NOT be registered for a plain SkillStore") + } + + // SkillManageStore-capable store: both list/get and update should be + // registered, matching NewCRUDServer's `if manage, ok := ...; ok` branch. + srv2 := newTestMCPServer() + manage := newFakeSkillManageStore() + registerSkillCRUDTools(srv2, manage) + registerSkillUpdateCRUDTool(srv2, manage, manage) + if tool := srv2.GetTool("goclaw_skills_update"); tool == nil { + t.Error("expected goclaw_skills_update to be registered for a SkillManageStore-capable store") + } +} + +// TestNewCRUDServer_ConfigOnly_Constructs is a smoke test that a single +// non-nil dependency (Config) still produces a working server without +// requiring every other field to be populated. +func TestNewCRUDServer_ConfigOnly_Constructs(t *testing.T) { + cfg := &config.Config{} + httpServer := NewCRUDServer(CRUDDeps{Config: cfg}, "test") + if httpServer == nil { + t.Fatal("expected non-nil StreamableHTTPServer with only Config set") + } +} + +// TestResolveMCPTenantID_HeaderPresentAndValid_ScopesToThatTenant verifies +// that a valid "X-GoClaw-Tenant-Id" header (UUID form) resolves to the +// matching tenant. +func TestResolveMCPTenantID_HeaderPresentAndValid_ScopesToThatTenant(t *testing.T) { + tenants := newFakeTenantStore() + want := &store.TenantData{ID: uuid.New(), Slug: "acme"} + tenants.addTenant(want) + + got := resolveMCPTenantID(context.Background(), tenants, want.ID.String()) + if got != want.ID { + t.Fatalf("expected tenant %s, got %s", want.ID, got) + } +} + +// TestResolveMCPTenantID_HeaderPresentSlug_ScopesToThatTenant verifies slug +// lookups (non-UUID header values) also resolve correctly. +func TestResolveMCPTenantID_HeaderPresentSlug_ScopesToThatTenant(t *testing.T) { + tenants := newFakeTenantStore() + want := &store.TenantData{ID: uuid.New(), Slug: "acme"} + tenants.addTenant(want) + + got := resolveMCPTenantID(context.Background(), tenants, "acme") + if got != want.ID { + t.Fatalf("expected tenant %s, got %s", want.ID, got) + } +} + +// TestResolveMCPTenantID_HeaderAbsent_DefaultsToMasterTenant verifies the +// fail-safe default (master tenant, not uuid.Nil/unscoped) when the caller +// supplies no tenant header at all. +func TestResolveMCPTenantID_HeaderAbsent_DefaultsToMasterTenant(t *testing.T) { + tenants := newFakeTenantStore() + + got := resolveMCPTenantID(context.Background(), tenants, "") + if got != store.MasterTenantID { + t.Fatalf("expected MasterTenantID, got %s", got) + } +} + +// TestResolveMCPTenantID_HeaderUnresolvable_DefaultsToMasterTenant verifies +// that an invalid/unknown tenant header fails safe to the master tenant +// rather than silently falling through to an unscoped (uuid.Nil) context — +// unscoped context previously allowed writes to leak into the wrong tenant +// (or master) depending on downstream store fallback behavior. +func TestResolveMCPTenantID_HeaderUnresolvable_DefaultsToMasterTenant(t *testing.T) { + tenants := newFakeTenantStore() + + got := resolveMCPTenantID(context.Background(), tenants, uuid.New().String()) + if got != store.MasterTenantID { + t.Fatalf("expected MasterTenantID fallback for unknown tenant id, got %s", got) + } + + got = resolveMCPTenantID(context.Background(), tenants, "not-a-real-slug") + if got != store.MasterTenantID { + t.Fatalf("expected MasterTenantID fallback for unknown tenant slug, got %s", got) + } +} + +// TestResolveMCPTenantID_NilTenantStore_DefaultsToMasterTenant verifies the +// server still degrades gracefully (falls back to master tenant rather than +// panicking or leaving tenant scope unset) when CRUDDeps.Tenants is nil — +// e.g. an edition/build that never wired a tenant store. +func TestResolveMCPTenantID_NilTenantStore_DefaultsToMasterTenant(t *testing.T) { + got := resolveMCPTenantID(context.Background(), nil, uuid.New().String()) + if got != store.MasterTenantID { + t.Fatalf("expected MasterTenantID with nil tenant store, got %s", got) + } +} diff --git a/internal/mcp/crud_sessions.go b/internal/mcp/crud_sessions.go new file mode 100644 index 00000000..8c91f574 --- /dev/null +++ b/internal/mcp/crud_sessions.go @@ -0,0 +1,176 @@ +package mcp + +import ( + "context" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerSessionCRUDTools registers the goclaw_sessions_* MCP tools backed by store.SessionStore. +func registerSessionCRUDTools(srv *mcpserver.MCPServer, sessions store.SessionStore) { + srv.AddTool(mcpgo.NewTool("goclaw_sessions_list", + mcpgo.WithDescription("List session keys for a given agent."), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID to list sessions for.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleSessionsList(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_get", + mcpgo.WithDescription("Get a session's current state (label, summary, message count) by key."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleSessionsGet(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_preview", + mcpgo.WithDescription("Return the message history and summary for a goclaw session."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleSessionsPreview(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_patch", + mcpgo.WithDescription("Update label, model, and/or metadata on a goclaw session."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithString("label", mcpgo.Description("New session label.")), + mcpgo.WithString("model", mcpgo.Description("New model name.")), + mcpgo.WithObject("metadata", mcpgo.Description("Metadata key/value pairs to set (replaces existing metadata).")), + ), handleSessionsPatch(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_delete", + mcpgo.WithDescription("Delete a goclaw session."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleSessionsDelete(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_reset", + mcpgo.WithDescription("Reset a goclaw session's transcript, clearing its message history."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleSessionsReset(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_sessions_compact", + mcpgo.WithDescription("Compact a goclaw session's history, keeping only the most recent messages."), + mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Session key.")), + mcpgo.WithNumber("keep_last", mcpgo.Description("Number of most-recent messages to keep.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleSessionsCompact(sessions)) +} + +func handleSessionsList(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + agentID, err := req.RequireString("agent_id") + if err != nil { + return toolError("sessions.list", err) + } + list := sessions.List(ctx, agentID) + return jsonToolResult(list) + } +} + +func handleSessionsGet(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.get", err) + } + sess := sessions.Get(ctx, key) + if sess == nil { + return mcpgo.NewToolResultError("sessions.get: session not found: " + key), nil + } + return jsonToolResult(sess) + } +} + +func handleSessionsPreview(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.preview", err) + } + sess := sessions.Get(ctx, key) + if sess == nil { + return mcpgo.NewToolResultError("sessions.preview: session not found: " + key), nil + } + return jsonToolResult(map[string]any{ + "key": key, + "messages": sess.Messages, + "summary": sess.Summary, + }) + } +} + +func handleSessionsPatch(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.patch", err) + } + sess := sessions.Get(ctx, key) + if sess == nil { + return mcpgo.NewToolResultError("sessions.patch: session not found: " + key), nil + } + + if label := req.GetString("label", ""); label != "" { + sessions.SetLabel(ctx, key, label) + } + if model := req.GetString("model", ""); model != "" { + sessions.UpdateMetadata(ctx, key, model, sess.Provider, sess.Channel) + } + if args := req.GetArguments(); args != nil { + if raw, ok := args["metadata"]; ok { + if metaMap, ok := raw.(map[string]any); ok { + metadata := make(map[string]string, len(metaMap)) + for k, v := range metaMap { + if s, ok := v.(string); ok { + metadata[k] = s + } + } + sessions.SetSessionMetadata(ctx, key, metadata) + } + } + } + return jsonToolResult(map[string]any{"ok": true, "key": key}) + } +} + +func handleSessionsDelete(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.delete", err) + } + if err := sessions.Delete(ctx, key); err != nil { + return toolError("sessions.delete", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleSessionsReset(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.reset", err) + } + sessions.Reset(ctx, key) + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +const defaultSessionCompactKeepLast = 20 + +func handleSessionsCompact(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + key, err := req.RequireString("key") + if err != nil { + return toolError("sessions.compact", err) + } + keepLast := int(req.GetFloat("keep_last", defaultSessionCompactKeepLast)) + history := sessions.GetHistory(ctx, key) + original := len(history) + sessions.TruncateHistory(ctx, key, keepLast) + kept := min(original, keepLast) + return jsonToolResult(map[string]any{"ok": true, "original": original, "kept": kept}) + } +} diff --git a/internal/mcp/crud_sessions_test.go b/internal/mcp/crud_sessions_test.go new file mode 100644 index 00000000..4b5c4ed2 --- /dev/null +++ b/internal/mcp/crud_sessions_test.go @@ -0,0 +1,108 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestSessionsList_RequiresAgentID(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + missing := callTool(t, srv, "goclaw_sessions_list", map[string]any{}) + assert.True(t, toolIsError(missing)) + + sessions.add("s1:agent1", &store.SessionData{Key: "s1:agent1"}) + ok := callTool(t, srv, "goclaw_sessions_list", map[string]any{"agent_id": "agent1"}) + require.False(t, toolIsError(ok)) +} + +func TestSessionsGet_NotFound(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_get", map[string]any{"key": "missing"}) + assert.True(t, toolIsError(result)) +} + +func TestSessionsGet_Found(t *testing.T) { + sessions := newFakeSessionStore() + sessions.add("sess-1", &store.SessionData{Key: "sess-1", Label: "hello"}) + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_get", map[string]any{"key": "sess-1"}) + require.False(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "hello") +} + +func TestSessionsPatch_UpdatesLabelAndMetadata(t *testing.T) { + sessions := newFakeSessionStore() + sessions.add("sess-1", &store.SessionData{Key: "sess-1"}) + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_patch", map[string]any{ + "key": "sess-1", + "label": "renamed", + "metadata": map[string]any{"foo": "bar"}, + }) + require.False(t, toolIsError(result)) + assert.Equal(t, "renamed", sessions.labels["sess-1"]) + assert.Equal(t, "bar", sessions.metadata["sess-1"]["foo"]) +} + +func TestSessionsPatch_NotFound(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_patch", map[string]any{"key": "missing"}) + assert.True(t, toolIsError(result)) +} + +func TestSessionsDelete_HappyAndErrorPath(t *testing.T) { + sessions := newFakeSessionStore() + sessions.add("sess-1", &store.SessionData{Key: "sess-1"}) + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + ok := callTool(t, srv, "goclaw_sessions_delete", map[string]any{"key": "sess-1"}) + require.False(t, toolIsError(ok)) + assert.Contains(t, sessions.deleted, "sess-1") + + sessions.deleteErr = assertErr("boom") + failed := callTool(t, srv, "goclaw_sessions_delete", map[string]any{"key": "sess-2"}) + assert.True(t, toolIsError(failed)) +} + +func TestSessionsReset(t *testing.T) { + sessions := newFakeSessionStore() + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_reset", map[string]any{"key": "sess-1"}) + require.False(t, toolIsError(result)) + assert.Contains(t, sessions.resetKeys, "sess-1") +} + +func TestSessionsCompact_TruncatesHistory(t *testing.T) { + sessions := newFakeSessionStore() + sessions.history["sess-1"] = []providers.Message{ + {Role: "user", Content: "1"}, {Role: "user", Content: "2"}, {Role: "user", Content: "3"}, + } + srv := newTestMCPServer() + registerSessionCRUDTools(srv, sessions) + + result := callTool(t, srv, "goclaw_sessions_compact", map[string]any{"key": "sess-1", "keep_last": 1}) + require.False(t, toolIsError(result)) + assert.Len(t, sessions.history["sess-1"], 1) + assert.Contains(t, toolResultText(result), `"original":3`) +} diff --git a/internal/mcp/crud_skills.go b/internal/mcp/crud_skills.go new file mode 100644 index 00000000..d0683342 --- /dev/null +++ b/internal/mcp/crud_skills.go @@ -0,0 +1,179 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerSkillCRUDTools registers the goclaw_skills_* MCP tools backed by store.SkillStore. +func registerSkillCRUDTools(srv *mcpserver.MCPServer, skills store.SkillStore) { + srv.AddTool(mcpgo.NewTool("goclaw_skills_list", + mcpgo.WithDescription("List all skills known to goclaw."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleSkillsList(skills)) + + srv.AddTool(mcpgo.NewTool("goclaw_skills_get", + mcpgo.WithDescription("Get metadata for a single skill by name."), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Skill name.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleSkillsGet(skills)) +} + +func handleSkillsList(skills store.SkillStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + list := skills.ListSkills(ctx) + return jsonToolResult(list) + } +} + +func handleSkillsGet(skills store.SkillStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return toolError("skills.get", err) + } + skill, ok := skills.GetSkill(ctx, name) + if !ok { + return mcpgo.NewToolResultError("skills.get: skill not found: " + name), nil + } + return jsonToolResult(skill) + } +} + +// registerSkillUpdateCRUDTool registers goclaw_skills_update. Only wired when +// the skill store also implements store.SkillManageStore (e.g. PGSkillStore); +// stores that don't support updates (e.g. FileSkillStore) simply don't get +// this tool registered. +func registerSkillUpdateCRUDTool(srv *mcpserver.MCPServer, skills store.SkillStore, manage store.SkillManageStore) { + srv.AddTool(mcpgo.NewTool("goclaw_skills_update", + mcpgo.WithDescription("Update a goclaw skill's metadata by name or id, applying the given field updates."), + mcpgo.WithString("name", mcpgo.Description("Skill name; used to resolve the skill if id is not given.")), + mcpgo.WithString("id", mcpgo.Description("Skill UUID.")), + mcpgo.WithObject("updates", mcpgo.Required(), mcpgo.Description("Field updates to apply (e.g. {\"visibility\": \"tenant\"}).")), + ), handleSkillsUpdate(skills, manage)) +} + +func handleSkillsUpdate(skills store.SkillStore, manage store.SkillManageStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name := req.GetString("name", "") + idStr := req.GetString("id", "") + if name == "" && idStr == "" { + return mcpgo.NewToolResultError("skills.update: one of name or id is required"), nil + } + + var skillID uuid.UUID + if idStr != "" { + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("skills.update", fmt.Errorf("invalid id: %w", err)) + } + skillID = id + } else { + info, ok := skills.GetSkill(ctx, name) + if !ok { + return mcpgo.NewToolResultError("skills.update: skill not found: " + name), nil + } + id, err := uuid.Parse(info.ID) + if err != nil { + return toolError("skills.update", fmt.Errorf("cannot resolve skill id: %w", err)) + } + skillID = id + } + + args := req.GetArguments() + rawUpdates, ok := args["updates"].(map[string]any) + if !ok || len(rawUpdates) == 0 { + return mcpgo.NewToolResultError("skills.update: updates is required"), nil + } + + if err := manage.UpdateSkill(ctx, skillID, rawUpdates); err != nil { + return toolError("skills.update", err) + } + skills.BumpVersion() + return jsonToolResult(map[string]string{"ok": "true"}) + } +} + +// registerSkillWriteFileCRUDTool registers goclaw_skills_write_file, letting +// MCP callers edit a managed (non-system) skill's file content on disk — +// mirroring the web UI's skill file editor (SkillsHandler.handleWriteFile in +// internal/http/skills_versions.go). Both surfaces call the same +// skills.WriteVersionedFile helper so validation and versioning stay +// identical. Only wired when the skill store implements +// store.SkillManageStore, same gate as registerSkillUpdateCRUDTool. +func registerSkillWriteFileCRUDTool(srv *mcpserver.MCPServer, skillStore store.SkillStore, manage store.SkillManageStore, cfg *config.Config) { + srv.AddTool(mcpgo.NewTool("goclaw_skills_write_file", + mcpgo.WithDescription("Write a file's content within a managed (non-system) skill, creating a new immutable version of that skill."), + mcpgo.WithString("name", mcpgo.Description("Skill name; used to resolve the skill if id is not given.")), + mcpgo.WithString("id", mcpgo.Description("Skill UUID.")), + mcpgo.WithString("path", mcpgo.Required(), mcpgo.Description("File path relative to the skill's directory (e.g. \"SKILL.md\").")), + mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("New full content of the file.")), + ), handleSkillsWriteFile(skillStore, manage, cfg)) +} + +func handleSkillsWriteFile(skillStore store.SkillStore, manage store.SkillManageStore, cfg *config.Config) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name := req.GetString("name", "") + idStr := req.GetString("id", "") + if name == "" && idStr == "" { + return mcpgo.NewToolResultError("skills.write_file: one of name or id is required"), nil + } + + relPath, err := req.RequireString("path") + if err != nil { + return toolError("skills.write_file", err) + } + content, err := req.RequireString("content") + if err != nil { + return toolError("skills.write_file", err) + } + + var skillID uuid.UUID + if idStr != "" { + id, err := uuid.Parse(idStr) + if err != nil { + return toolError("skills.write_file", fmt.Errorf("invalid id: %w", err)) + } + skillID = id + } else { + info, ok := skillStore.GetSkill(ctx, name) + if !ok { + return mcpgo.NewToolResultError("skills.write_file: skill not found: " + name), nil + } + id, err := uuid.Parse(info.ID) + if err != nil { + return toolError("skills.write_file", fmt.Errorf("cannot resolve skill id: %w", err)) + } + skillID = id + } + + tenantID := store.TenantIDFromContext(ctx) + tenantSlug := store.TenantSlugFromContext(ctx) + tenantSkillsDir := config.TenantSkillsStoreDir(cfg.DataDir, tenantID, tenantSlug) + + path, version, err := skills.WriteVersionedFile(ctx, manage, tenantSkillsDir, skillID, relPath, content) + if err != nil { + switch { + case errors.Is(err, skills.ErrSkillFileNotFound): + return mcpgo.NewToolResultError("skills.write_file: file or skill not found"), nil + case errors.Is(err, skills.ErrSkillIsSystem): + return mcpgo.NewToolResultError("skills.write_file: cannot edit a system skill"), nil + case errors.Is(err, skills.ErrSkillInvalidPath): + return mcpgo.NewToolResultError("skills.write_file: invalid file path"), nil + default: + return toolError("skills.write_file", err) + } + } + return jsonToolResult(map[string]any{"ok": "true", "path": path, "version": version}) + } +} diff --git a/internal/mcp/crud_skills_test.go b/internal/mcp/crud_skills_test.go new file mode 100644 index 00000000..59587623 --- /dev/null +++ b/internal/mcp/crud_skills_test.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestSkillsList_And_Get(t *testing.T) { + skills := newFakeSkillStore() + skills.skills["writer"] = store.SkillInfo{Name: "writer", Slug: "writer", ID: uuid.New().String()} + srv := newTestMCPServer() + registerSkillCRUDTools(srv, skills) + + list := callTool(t, srv, "goclaw_skills_list", map[string]any{}) + require.False(t, toolIsError(list)) + assert.Contains(t, toolResultText(list), "writer") + + got := callTool(t, srv, "goclaw_skills_get", map[string]any{"name": "writer"}) + require.False(t, toolIsError(got)) + assert.Contains(t, toolResultText(got), "writer") + + notFound := callTool(t, srv, "goclaw_skills_get", map[string]any{"name": "missing"}) + assert.True(t, toolIsError(notFound)) +} + +func TestSkillsUpdate_RequiresNameOrID(t *testing.T) { + manage := newFakeSkillManageStore() + srv := newTestMCPServer() + registerSkillCRUDTools(srv, manage) + registerSkillUpdateCRUDTool(srv, manage, manage) + + missing := callTool(t, srv, "goclaw_skills_update", map[string]any{"updates": map[string]any{"visibility": "tenant"}}) + assert.True(t, toolIsError(missing)) +} + +func TestSkillsUpdate_ByID_HappyPath(t *testing.T) { + manage := newFakeSkillManageStore() + skillID := uuid.New() + srv := newTestMCPServer() + registerSkillCRUDTools(srv, manage) + registerSkillUpdateCRUDTool(srv, manage, manage) + + result := callTool(t, srv, "goclaw_skills_update", map[string]any{ + "id": skillID.String(), + "updates": map[string]any{"visibility": "tenant"}, + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Equal(t, "tenant", manage.updateCalls[skillID]["visibility"]) + assert.Equal(t, 1, manage.bumpCount) +} + +func TestSkillsUpdate_MissingUpdatesField(t *testing.T) { + manage := newFakeSkillManageStore() + srv := newTestMCPServer() + registerSkillCRUDTools(srv, manage) + registerSkillUpdateCRUDTool(srv, manage, manage) + + result := callTool(t, srv, "goclaw_skills_update", map[string]any{"id": uuid.New().String()}) + assert.True(t, toolIsError(result)) +} + +func TestSkillsUpdate_ByName_SkillNotFound(t *testing.T) { + manage := newFakeSkillManageStore() + srv := newTestMCPServer() + registerSkillCRUDTools(srv, manage) + registerSkillUpdateCRUDTool(srv, manage, manage) + + result := callTool(t, srv, "goclaw_skills_update", map[string]any{ + "name": "missing", + "updates": map[string]any{"visibility": "tenant"}, + }) + assert.True(t, toolIsError(result)) +} diff --git a/internal/mcp/crud_teams.go b/internal/mcp/crud_teams.go new file mode 100644 index 00000000..fba68323 --- /dev/null +++ b/internal/mcp/crud_teams.go @@ -0,0 +1,345 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// registerTeamsCRUDTools registers the goclaw_teams_* MCP tools backed by +// store.TeamStore. agents is used to resolve agent_key/UUID inputs for lead, +// members, and add/remove-member operations. +func registerTeamsCRUDTools(srv *mcpserver.MCPServer, teams store.TeamStore, agents store.AgentStore) { + srv.AddTool(mcpgo.NewTool("goclaw_teams_list", + mcpgo.WithDescription("List teams visible to the caller."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsList(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_create", + mcpgo.WithDescription("Create a new team."), + mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Team name.")), + mcpgo.WithString("lead", mcpgo.Required(), mcpgo.Description("Lead agent key or UUID.")), + mcpgo.WithArray("members", mcpgo.Required(), mcpgo.Description("Member agent keys or UUIDs (at least 1).")), + mcpgo.WithString("description", mcpgo.Description("Team description.")), + ), handleTeamsCreate(teams, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_get", + mcpgo.WithDescription("Fetch a single team with its member list."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsGet(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_delete", + mcpgo.WithDescription("Delete a team."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleTeamsDelete(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_update", + mcpgo.WithDescription("Apply a partial update to a team's settings."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("name", mcpgo.Description("New team name.")), + mcpgo.WithString("description", mcpgo.Description("New team description.")), + mcpgo.WithObject("settings", mcpgo.Description("New team settings object (merged, not replaced).")), + ), handleTeamsUpdate(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_known_users", + mcpgo.WithDescription("List user IDs known to have interacted with a team."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsKnownUsers(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_scopes", + mcpgo.WithDescription("List distinct (channel, chatID) task scopes for a team."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsScopes(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_events_list", + mcpgo.WithDescription("List audit events for a team's tasks."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return.")), + mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsEventsList(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_members_add", + mcpgo.WithDescription("Add an agent to a team."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("agent", mcpgo.Required(), mcpgo.Description("Agent key or UUID to add.")), + mcpgo.WithString("role", mcpgo.Enum("member", "reviewer"), mcpgo.Description("Member role; defaults to \"member\".")), + ), handleTeamsMembersAdd(teams, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_members_remove", + mcpgo.WithDescription("Remove an agent from a team."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent key or UUID to remove.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleTeamsMembersRemove(teams, agents)) +} + +func handleTeamsList(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + list, err := teams.ListTeams(ctx) + if err != nil { + return toolError("teams.list", err) + } + return jsonToolResult(map[string]any{"teams": list, "count": len(list)}) + } +} + +func handleTeamsCreate(teams store.TeamStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return toolError("teams.create", err) + } + lead, err := req.RequireString("lead") + if err != nil { + return toolError("teams.create", err) + } + members, err := req.RequireStringSlice("members") + if err != nil { + return toolError("teams.create", err) + } + if len(members) == 0 { + return mcpgo.NewToolResultError("teams.create: at least 1 member is required"), nil + } + + leadAgent, err := resolveAgentInfo(ctx, agents, lead) + if err != nil { + return toolError("teams.create", fmt.Errorf("lead agent: %w", err)) + } + if existing, _ := teams.GetTeamForAgent(ctx, leadAgent.ID); existing != nil && existing.LeadAgentID == leadAgent.ID { + return mcpgo.NewToolResultError(fmt.Sprintf("teams.create: agent %q already leads team %q", lead, existing.Name)), nil + } + + memberAgents := make([]*store.AgentData, 0, len(members)) + for _, m := range members { + ag, err := resolveAgentInfo(ctx, agents, m) + if err != nil { + return toolError("teams.create", fmt.Errorf("member agent %s: %w", m, err)) + } + memberAgents = append(memberAgents, ag) + } + + team := &store.TeamData{ + Name: name, + LeadAgentID: leadAgent.ID, + Description: req.GetString("description", ""), + Status: store.TeamStatusActive, + } + if err := teams.CreateTeam(ctx, team); err != nil { + return toolError("teams.create", err) + } + if err := teams.AddMember(ctx, team.ID, leadAgent.ID, store.TeamRoleLead); err != nil { + return toolError("teams.create", err) + } + for _, ag := range memberAgents { + if ag.ID == leadAgent.ID { + continue + } + if err := teams.AddMember(ctx, team.ID, ag.ID, store.TeamRoleMember); err != nil { + return toolError("teams.create", err) + } + } + return jsonToolResult(map[string]any{"team": team}) + } +} + +func handleTeamsGet(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.get", err) + } + team, err := teams.GetTeam(ctx, teamID) + if err != nil { + return toolError("teams.get", err) + } + members, err := teams.ListMembers(ctx, teamID) + if err != nil { + return toolError("teams.get", err) + } + return jsonToolResult(map[string]any{"team": team, "members": members}) + } +} + +func handleTeamsDelete(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.delete", err) + } + if err := teams.DeleteTeam(ctx, teamID); err != nil { + return toolError("teams.delete", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsUpdate(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.update", err) + } + if _, err := teams.GetTeam(ctx, teamID); err != nil { + return toolError("teams.update", err) + } + + updates := map[string]any{} + if name := req.GetString("name", ""); name != "" { + updates["name"] = name + } + args := req.GetArguments() + if desc, ok := args["description"].(string); ok { + updates["description"] = desc + } + if settings, ok := args["settings"]; ok { + raw, err := json.Marshal(settings) + if err != nil { + return toolError("teams.update", err) + } + updates["settings"] = json.RawMessage(raw) + } + if len(updates) == 0 { + return jsonToolResult(map[string]bool{"ok": true}) + } + if err := teams.UpdateTeam(ctx, teamID, updates); err != nil { + return toolError("teams.update", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsKnownUsers(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.known_users", err) + } + const defaultLimit = 100 + users, err := teams.KnownUserIDs(ctx, teamID, defaultLimit) + if err != nil { + return toolError("teams.known_users", err) + } + return jsonToolResult(map[string]any{"users": users}) + } +} + +func handleTeamsScopes(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.scopes", err) + } + scopes, err := teams.ListTaskScopes(ctx, teamID) + if err != nil { + return toolError("teams.scopes", err) + } + return jsonToolResult(map[string]any{"scopes": scopes}) + } +} + +func handleTeamsEventsList(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.events.list", err) + } + limit := int(req.GetFloat("limit", 0)) + offset := int(req.GetFloat("offset", 0)) + events, err := teams.ListTeamEvents(ctx, teamID, limit, offset) + if err != nil { + return toolError("teams.events.list", err) + } + if events == nil { + events = []store.TeamTaskEventData{} + } + return jsonToolResult(map[string]any{"events": events, "count": len(events)}) + } +} + +func handleTeamsMembersAdd(teams store.TeamStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.members.add", err) + } + agentRef, err := req.RequireString("agent") + if err != nil { + return toolError("teams.members.add", err) + } + team, err := teams.GetTeam(ctx, teamID) + if err != nil { + return toolError("teams.members.add", err) + } + ag, err := resolveAgentInfo(ctx, agents, agentRef) + if err != nil { + return toolError("teams.members.add", fmt.Errorf("invalid agent: %w", err)) + } + if ag.ID == team.LeadAgentID { + return mcpgo.NewToolResultError("teams.members.add: agent is already the team lead"), nil + } + role := req.GetString("role", store.TeamRoleMember) + switch role { + case store.TeamRoleMember, store.TeamRoleReviewer: + default: + return mcpgo.NewToolResultError("teams.members.add: role must be member or reviewer"), nil + } + if err := teams.AddMember(ctx, teamID, ag.ID, role); err != nil { + return toolError("teams.members.add", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsMembersRemove(teams store.TeamStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.members.remove", err) + } + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("teams.members.remove", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("teams.members.remove", fmt.Errorf("invalid agent_id: %w", err)) + } + team, err := teams.GetTeam(ctx, teamID) + if err != nil { + return toolError("teams.members.remove", err) + } + if agentID == team.LeadAgentID { + return mcpgo.NewToolResultError("teams.members.remove: cannot remove the team lead"), nil + } + if err := teams.RemoveMember(ctx, teamID, agentID); err != nil { + return toolError("teams.members.remove", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +// parseTeamID extracts and parses the required team_id parameter shared by +// nearly all goclaw_teams_* and goclaw_teams_tasks_* tools. +func parseTeamID(req mcpgo.CallToolRequest) (uuid.UUID, error) { + teamIDStr, err := req.RequireString("team_id") + if err != nil { + return uuid.Nil, err + } + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid team_id: %w", err) + } + return teamID, nil +} diff --git a/internal/mcp/crud_teams_tasks.go b/internal/mcp/crud_teams_tasks.go new file mode 100644 index 00000000..7408fe8a --- /dev/null +++ b/internal/mcp/crud_teams_tasks.go @@ -0,0 +1,430 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// maxTaskCommentLength mirrors internal/gateway/methods/teams_tasks_mutations.go's +// maxCommentLength cap on comment/reason content, to prevent DB bloat. +const maxTaskCommentLength = 10000 + +// registerTeamsTasksCRUDTools registers the goclaw_teams_tasks_* MCP tools +// backed by store.TeamStore. agents resolves agent_key/UUID inputs for assign. +func registerTeamsTasksCRUDTools(srv *mcpserver.MCPServer, teams store.TeamStore, agents store.AgentStore) { + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_list", + mcpgo.WithDescription("List a team's tasks, optionally filtered by status/channel/chatID."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("status", mcpgo.Description("Status filter: \"\" (active), \"completed\", or \"all\".")), + mcpgo.WithString("channel", mcpgo.Description("Scope filter: channel name.")), + mcpgo.WithString("chat_id", mcpgo.Description("Scope filter: chat ID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksList(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_active_by_session", + mcpgo.WithDescription("List active tasks scoped to a session/chat ID (for sidebar-style views)."), + mcpgo.WithString("session_key", mcpgo.Required(), mcpgo.Description("Session/chat key.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksActiveBySession(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_events", + mcpgo.WithDescription("List audit events for a single task."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksEvents(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_create", + mcpgo.WithDescription("Create a new task in a team's shared task list."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("subject", mcpgo.Required(), mcpgo.Description("Task subject (max 500 chars).")), + mcpgo.WithString("description", mcpgo.Description("Task description.")), + mcpgo.WithNumber("priority", mcpgo.Description("Task priority.")), + mcpgo.WithString("task_type", mcpgo.Description("Task type; defaults to \"general\".")), + mcpgo.WithString("assign_to", mcpgo.Description("Agent UUID to assign immediately after creation.")), + mcpgo.WithString("channel", mcpgo.Description("Origin channel; defaults to \"dashboard\".")), + mcpgo.WithString("chat_id", mcpgo.Description("Origin chat ID; defaults to the team ID.")), + ), handleTeamsTasksCreate(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_delete", + mcpgo.WithDescription("Hard-delete a task in a terminal status."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleTeamsTasksDelete(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_delete_bulk", + mcpgo.WithDescription("Hard-delete multiple tasks in a terminal status."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithArray("task_ids", mcpgo.Required(), mcpgo.Description("Task UUIDs to delete.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleTeamsTasksDeleteBulk(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_assign", + mcpgo.WithDescription("Assign a task to a team member (does not dispatch to the agent runtime — MCP surface only)."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Assignee agent key or UUID.")), + ), handleTeamsTasksAssign(teams, agents)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_get", + mcpgo.WithDescription("Fetch a task with its comments, events, and attachments."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksGet(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_get_light", + mcpgo.WithDescription("Fetch a task only (no comments/events/attachments)."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksGetLight(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_approve", + mcpgo.WithDescription("Approve a task in review, optionally with a comment."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithString("comment", mcpgo.Description("Optional approval comment.")), + ), handleTeamsTasksApprove(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_reject", + mcpgo.WithDescription("Reject a task in review, with a reason."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithString("reason", mcpgo.Description("Rejection reason; defaults to \"Rejected by human\".")), + ), handleTeamsTasksReject(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_comment", + mcpgo.WithDescription("Add a comment to a task."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("Comment content.")), + mcpgo.WithString("user_id", mcpgo.Description("Author user ID.")), + ), handleTeamsTasksComment(teams)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_tasks_comments", + mcpgo.WithDescription("List comments on a task."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("task_id", mcpgo.Required(), mcpgo.Description("Task UUID.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsTasksComments(teams)) +} + +func parseTeamTaskIDs(req mcpgo.CallToolRequest) (teamID, taskID uuid.UUID, err error) { + teamID, err = parseTeamID(req) + if err != nil { + return uuid.Nil, uuid.Nil, err + } + taskIDStr, err := req.RequireString("task_id") + if err != nil { + return uuid.Nil, uuid.Nil, err + } + taskID, err = uuid.Parse(taskIDStr) + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("invalid task_id: %w", err) + } + return teamID, taskID, nil +} + +// getTaskInTeam fetches a task and verifies it belongs to teamID, preventing +// cross-team IDOR — mirrors the belongs-to-team check in +// internal/gateway/methods/teams_tasks.go. +func getTaskInTeam(ctx context.Context, teams store.TeamStore, teamID, taskID uuid.UUID) (*store.TeamTaskData, error) { + task, err := teams.GetTask(ctx, taskID) + if err != nil { + return nil, err + } + if task.TeamID != teamID { + return nil, fmt.Errorf("task not found in team") + } + return task, nil +} + +func handleTeamsTasksList(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.tasks.list", err) + } + const dashboardLimit = 200 + status := req.GetString("status", "") + channel := req.GetString("channel", "") + chatID := req.GetString("chat_id", "") + tasks, err := teams.ListTasks(ctx, teamID, "newest", status, "", channel, chatID, dashboardLimit, 0) + if err != nil { + return toolError("teams.tasks.list", err) + } + if len(tasks) > dashboardLimit { + tasks = tasks[:dashboardLimit] + } + return jsonToolResult(map[string]any{"tasks": tasks, "count": len(tasks)}) + } +} + +func handleTeamsTasksActiveBySession(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + sessionKey, err := req.RequireString("session_key") + if err != nil { + return toolError("teams.tasks.active-by-session", err) + } + tasks, err := teams.ListActiveTasksByChatID(ctx, sessionKey) + if err != nil { + return toolError("teams.tasks.active-by-session", err) + } + return jsonToolResult(map[string]any{"tasks": tasks}) + } +} + +func handleTeamsTasksEvents(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.events", err) + } + if _, err := getTaskInTeam(ctx, teams, teamID, taskID); err != nil { + return toolError("teams.tasks.events", err) + } + events, err := teams.ListTaskEvents(ctx, taskID) + if err != nil { + return toolError("teams.tasks.events", err) + } + return jsonToolResult(map[string]any{"events": events}) + } +} + +func handleTeamsTasksCreate(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.tasks.create", err) + } + subject, err := req.RequireString("subject") + if err != nil { + return toolError("teams.tasks.create", err) + } + const maxSubjectLen = 500 + if len(subject) > maxSubjectLen { + return mcpgo.NewToolResultError("teams.tasks.create: subject too long"), nil + } + description := req.GetString("description", "") + if len(description) > maxTaskCommentLength { + return mcpgo.NewToolResultError("teams.tasks.create: description too long"), nil + } + taskType := req.GetString("task_type", "general") + channel := req.GetString("channel", "dashboard") + chatID := req.GetString("chat_id", teamID.String()) + + task := &store.TeamTaskData{ + TeamID: teamID, + Subject: subject, + Description: description, + Status: store.TeamTaskStatusPending, + Priority: int(req.GetFloat("priority", 0)), + TaskType: taskType, + Channel: channel, + ChatID: chatID, + } + if err := teams.CreateTask(ctx, task); err != nil { + return toolError("teams.tasks.create", err) + } + + if assignTo := req.GetString("assign_to", ""); assignTo != "" { + if agentID, err := uuid.Parse(assignTo); err == nil { + if err := teams.AssignTask(ctx, task.ID, agentID, teamID); err == nil { + task.Status = store.TeamTaskStatusInProgress + task.OwnerAgentID = &agentID + } + } + } + return jsonToolResult(map[string]any{"task": task}) + } +} + +func handleTeamsTasksDelete(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.delete", err) + } + if _, err := getTaskInTeam(ctx, teams, teamID, taskID); err != nil { + return toolError("teams.tasks.delete", err) + } + if err := teams.DeleteTask(ctx, taskID, teamID); err != nil { + return toolError("teams.tasks.delete", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsTasksDeleteBulk(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.tasks.delete-bulk", err) + } + raw, err := req.RequireStringSlice("task_ids") + if err != nil { + return toolError("teams.tasks.delete-bulk", err) + } + taskUUIDs := make([]uuid.UUID, 0, len(raw)) + for _, s := range raw { + if id, err := uuid.Parse(s); err == nil { + taskUUIDs = append(taskUUIDs, id) + } + } + if len(taskUUIDs) == 0 { + return mcpgo.NewToolResultError("teams.tasks.delete-bulk: no valid task_ids"), nil + } + deleted, err := teams.DeleteTasks(ctx, taskUUIDs, teamID) + if err != nil { + return toolError("teams.tasks.delete-bulk", err) + } + return jsonToolResult(map[string]any{"deleted": len(deleted)}) + } +} + +func handleTeamsTasksAssign(teams store.TeamStore, agents store.AgentStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.assign", err) + } + agentRef, err := req.RequireString("agent_id") + if err != nil { + return toolError("teams.tasks.assign", err) + } + agentID, err := resolveAgentUUID(ctx, agents, agentRef) + if err != nil { + return toolError("teams.tasks.assign", fmt.Errorf("invalid agent_id: %w", err)) + } + if _, err := getTaskInTeam(ctx, teams, teamID, taskID); err != nil { + return toolError("teams.tasks.assign", err) + } + if err := teams.AssignTask(ctx, taskID, agentID, teamID); err != nil { + return toolError("teams.tasks.assign", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsTasksGet(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.get", err) + } + task, err := getTaskInTeam(ctx, teams, teamID, taskID) + if err != nil { + return toolError("teams.tasks.get", err) + } + comments, _ := teams.ListTaskComments(ctx, taskID) + events, _ := teams.ListTaskEvents(ctx, taskID) + attachments, _ := teams.ListTaskAttachments(ctx, taskID) + return jsonToolResult(map[string]any{ + "task": task, "comments": comments, "events": events, "attachments": attachments, + }) + } +} + +func handleTeamsTasksGetLight(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.get-light", err) + } + task, err := getTaskInTeam(ctx, teams, teamID, taskID) + if err != nil { + return toolError("teams.tasks.get-light", err) + } + return jsonToolResult(map[string]any{"task": task}) + } +} + +func handleTeamsTasksApprove(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.approve", err) + } + comment := req.GetString("comment", "") + if len(comment) > maxTaskCommentLength { + return mcpgo.NewToolResultError("teams.tasks.approve: comment too long"), nil + } + if err := teams.ApproveTask(ctx, taskID, teamID, comment); err != nil { + return toolError("teams.tasks.approve", err) + } + if comment != "" { + _ = teams.AddTaskComment(ctx, &store.TeamTaskCommentData{TaskID: taskID, Content: comment}) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsTasksReject(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.reject", err) + } + reason := req.GetString("reason", "Rejected by human") + if len(reason) > maxTaskCommentLength { + return mcpgo.NewToolResultError("teams.tasks.reject: reason too long"), nil + } + if err := teams.RejectTask(ctx, taskID, teamID, reason); err != nil { + return toolError("teams.tasks.reject", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsTasksComment(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.comment", err) + } + content, err := req.RequireString("content") + if err != nil { + return toolError("teams.tasks.comment", err) + } + if len(content) > maxTaskCommentLength { + return mcpgo.NewToolResultError("teams.tasks.comment: comment too long"), nil + } + if _, err := getTaskInTeam(ctx, teams, teamID, taskID); err != nil { + return toolError("teams.tasks.comment", err) + } + if err := teams.AddTaskComment(ctx, &store.TeamTaskCommentData{ + TaskID: taskID, + UserID: req.GetString("user_id", ""), + Content: content, + }); err != nil { + return toolError("teams.tasks.comment", err) + } + return jsonToolResult(map[string]bool{"ok": true}) + } +} + +func handleTeamsTasksComments(teams store.TeamStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, taskID, err := parseTeamTaskIDs(req) + if err != nil { + return toolError("teams.tasks.comments", err) + } + if _, err := getTaskInTeam(ctx, teams, teamID, taskID); err != nil { + return toolError("teams.tasks.comments", err) + } + comments, err := teams.ListTaskComments(ctx, taskID) + if err != nil { + return toolError("teams.tasks.comments", err) + } + return jsonToolResult(map[string]any{"comments": comments}) + } +} diff --git a/internal/mcp/crud_teams_test.go b/internal/mcp/crud_teams_test.go new file mode 100644 index 00000000..4d21ce2b --- /dev/null +++ b/internal/mcp/crud_teams_test.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestTeamsCreate_HappyPath(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + leadID := uuid.New() + memberID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: leadID}, AgentKey: "lead"}) + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: memberID}, AgentKey: "member"}) + + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + result := callTool(t, srv, "goclaw_teams_create", map[string]any{ + "name": "eng-team", + "lead": "lead", + "members": []any{"member"}, + }) + require.False(t, toolIsError(result), toolResultText(result)) + assert.Contains(t, toolResultText(result), "eng-team") + assert.Len(t, teams.teams, 1) +} + +func TestTeamsCreate_RequiresAtLeastOneMember(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + result := callTool(t, srv, "goclaw_teams_create", map[string]any{ + "name": "eng-team", "lead": "lead", "members": []any{}, + }) + assert.True(t, toolIsError(result)) +} + +func TestTeamsCreate_UnknownLeadAgent(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + result := callTool(t, srv, "goclaw_teams_create", map[string]any{ + "name": "eng-team", "lead": "missing-lead", "members": []any{"m"}, + }) + assert.True(t, toolIsError(result)) +} + +func TestTeamsGet_And_Delete(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + teamID := uuid.New() + teams.teams[teamID] = &store.TeamData{BaseModel: store.BaseModel{ID: teamID}, Name: "eng-team"} + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + got := callTool(t, srv, "goclaw_teams_get", map[string]any{"team_id": teamID.String()}) + require.False(t, toolIsError(got)) + assert.Contains(t, toolResultText(got), "eng-team") + + invalidID := callTool(t, srv, "goclaw_teams_get", map[string]any{"team_id": "not-a-uuid"}) + assert.True(t, toolIsError(invalidID)) + + deleted := callTool(t, srv, "goclaw_teams_delete", map[string]any{"team_id": teamID.String()}) + require.False(t, toolIsError(deleted)) + + getAfterDelete := callTool(t, srv, "goclaw_teams_get", map[string]any{"team_id": teamID.String()}) + assert.True(t, toolIsError(getAfterDelete)) +} + +func TestTeamsMembersAdd_RejectsAddingLeadAgain(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + teamID := uuid.New() + leadID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: leadID}, AgentKey: "lead"}) + teams.teams[teamID] = &store.TeamData{BaseModel: store.BaseModel{ID: teamID}, LeadAgentID: leadID} + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + result := callTool(t, srv, "goclaw_teams_members_add", map[string]any{"team_id": teamID.String(), "agent": "lead"}) + assert.True(t, toolIsError(result)) +} + +func TestTeamsMembersRemove_RejectsRemovingLead(t *testing.T) { + teams := newFakeTeamStore() + agents := newFakeAgentStore() + teamID := uuid.New() + leadID := uuid.New() + agents.add(&store.AgentData{BaseModel: store.BaseModel{ID: leadID}, AgentKey: "lead"}) + teams.teams[teamID] = &store.TeamData{BaseModel: store.BaseModel{ID: teamID}, LeadAgentID: leadID} + srv := newTestMCPServer() + registerTeamsCRUDTools(srv, teams, agents) + + result := callTool(t, srv, "goclaw_teams_members_remove", map[string]any{"team_id": teamID.String(), "agent_id": "lead"}) + assert.True(t, toolIsError(result)) +} diff --git a/internal/mcp/crud_teams_workspace.go b/internal/mcp/crud_teams_workspace.go new file mode 100644 index 00000000..ea69d9d4 --- /dev/null +++ b/internal/mcp/crud_teams_workspace.go @@ -0,0 +1,260 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +// registerTeamsWorkspaceCRUDTools registers the goclaw_teams_workspace_* +// MCP tools backed by the team workspace directory on disk. Mirrors +// internal/gateway/methods/teams_workspace.go (path resolution, symlink +// escape checks, shared-vs-isolated workspace mode). +func registerTeamsWorkspaceCRUDTools(srv *mcpserver.MCPServer, teams store.TeamStore, cfg *config.Config) { + srv.AddTool(mcpgo.NewTool("goclaw_teams_workspace_list", + mcpgo.WithDescription("List files in a team's workspace directory."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("chat_id", mcpgo.Description("Chat ID scope; empty lists shared/root or all chat scopes.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsWorkspaceList(teams, cfg)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_workspace_read", + mcpgo.WithDescription("Read a file from a team's workspace directory."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("chat_id", mcpgo.Description("Chat ID scope; required unless the team uses a shared workspace.")), + mcpgo.WithString("file_name", mcpgo.Required(), mcpgo.Description("File name, relative to the workspace scope.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleTeamsWorkspaceRead(teams, cfg)) + + srv.AddTool(mcpgo.NewTool("goclaw_teams_workspace_delete", + mcpgo.WithDescription("Delete a file from a team's workspace directory."), + mcpgo.WithString("team_id", mcpgo.Required(), mcpgo.Description("Team UUID.")), + mcpgo.WithString("chat_id", mcpgo.Description("Chat ID scope; required unless the team uses a shared workspace.")), + mcpgo.WithString("file_name", mcpgo.Required(), mcpgo.Description("File name, relative to the workspace scope.")), + mcpgo.WithDestructiveHintAnnotation(true), + ), handleTeamsWorkspaceDelete(teams, cfg)) +} + +// teamWorkspaceDir mirrors internal/gateway/methods/teams_workspace.go's +// unexported helper of the same name — duplicated because this MCP surface +// does not depend on internal/gateway/methods (see resolveAgentUUID doc). +// Tenant scoping uses store.TenantIDFromContext/TenantSlugFromContext same +// as the WS surface; MCP callers without an enriched context resolve to the +// master tenant. +func teamWorkspaceDir(ctx context.Context, dataDir string, teamID uuid.UUID, chatID string) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + base := config.TenantTeamDir(dataDir, tid, slug, teamID) + if chatID != "" { + return filepath.Join(base, chatID) + } + return base +} + +// resolveWorkspacePath mirrors internal/gateway/methods/teams_workspace.go's +// unexported helper of the same name (path traversal / symlink escape guard). +func resolveWorkspacePath(scopeDir, fileName string) (string, error) { + diskPath := filepath.Clean(filepath.Join(scopeDir, fileName)) + + scopeReal, err := filepath.EvalSymlinks(filepath.Clean(scopeDir)) + if err != nil { + scopeReal = filepath.Clean(scopeDir) + } + + diskReal, err := filepath.EvalSymlinks(diskPath) + if err != nil { + if !os.IsNotExist(err) { + slog.Warn("security.workspace_path_resolve_failed", "path", fileName, "error", err) + return "", fmt.Errorf("invalid file_name") + } + parentReal, parentErr := filepath.EvalSymlinks(filepath.Dir(diskPath)) + if parentErr != nil { + return "", fmt.Errorf("invalid file_name") + } + diskReal = filepath.Join(parentReal, filepath.Base(diskPath)) + } + + if diskReal != scopeReal && !strings.HasPrefix(diskReal, scopeReal+string(filepath.Separator)) { + slog.Warn("security.workspace_path_escape", "path", fileName, "resolved", diskReal, "scope", scopeReal) + return "", fmt.Errorf("invalid file_name") + } + + return diskPath, nil +} + +type teamWorkspaceFileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + ChatID string `json:"chat_id"` + IsDir bool `json:"is_dir,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func walkTeamWorkspaceDir(baseDir, prefix, chatID string) []teamWorkspaceFileEntry { + entries, err := os.ReadDir(baseDir) + if err != nil { + return nil + } + var files []teamWorkspaceFileEntry + for _, entry := range entries { + relPath := entry.Name() + if prefix != "" { + relPath = prefix + "/" + entry.Name() + } + if entry.IsDir() { + files = append(files, teamWorkspaceFileEntry{ + Name: relPath, Path: filepath.Join(baseDir, entry.Name()), ChatID: chatID, IsDir: true, + }) + files = append(files, walkTeamWorkspaceDir(filepath.Join(baseDir, entry.Name()), relPath, chatID)...) + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, teamWorkspaceFileEntry{ + Name: relPath, Path: filepath.Join(baseDir, entry.Name()), Size: info.Size(), ChatID: chatID, + UpdatedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"), + }) + } + return files +} + +func handleTeamsWorkspaceList(teams store.TeamStore, cfg *config.Config) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.workspace.list", err) + } + chatID := req.GetString("chat_id", "") + dataDir := cfg.ResolvedDataDir() + + shared := false + if team, err := teams.GetTeam(ctx, teamID); err == nil { + shared = tools.IsSharedWorkspace(team.Settings) + } + + baseDir := teamWorkspaceDir(ctx, dataDir, teamID, "") + var files []teamWorkspaceFileEntry + + if shared || chatID != "" { + scopeDir := baseDir + scopeChatID := "" + if !shared && chatID != "" { + scopeDir = teamWorkspaceDir(ctx, dataDir, teamID, chatID) + scopeChatID = chatID + } + files = walkTeamWorkspaceDir(scopeDir, "", scopeChatID) + } else { + entries, err := os.ReadDir(baseDir) + if err != nil { + return jsonToolResult(map[string]any{"files": []teamWorkspaceFileEntry{}, "count": 0}) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + cid := entry.Name() + scopeDir := filepath.Join(baseDir, cid) + files = append(files, teamWorkspaceFileEntry{Name: cid, Path: scopeDir, ChatID: cid, IsDir: true}) + files = append(files, walkTeamWorkspaceDir(scopeDir, cid, cid)...) + } + } + if files == nil { + files = []teamWorkspaceFileEntry{} + } + return jsonToolResult(map[string]any{"files": files, "count": len(files)}) + } +} + +func handleTeamsWorkspaceRead(teams store.TeamStore, cfg *config.Config) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.workspace.read", err) + } + fileName, err := req.RequireString("file_name") + if err != nil { + return toolError("teams.workspace.read", err) + } + if strings.Contains(fileName, "..") || strings.Contains(fileName, "\\") { + return mcpgo.NewToolResultError("teams.workspace.read: invalid file_name"), nil + } + + chatID := req.GetString("chat_id", "") + if team, err := teams.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + return mcpgo.NewToolResultError("teams.workspace.read: chat_id is required"), nil + } + + scopeDir := teamWorkspaceDir(ctx, cfg.ResolvedDataDir(), teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, fileName) + if pathErr != nil { + return toolError("teams.workspace.read", pathErr) + } + data, err := os.ReadFile(diskPath) + if err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("teams.workspace.read: file not found: %s", fileName)), nil + } + + const maxContentLen = 500000 + content := string(data) + if len(content) > maxContentLen { + content = content[:maxContentLen] + "\n\n[...truncated]" + } + + info, _ := os.Stat(diskPath) + file := teamWorkspaceFileEntry{Name: fileName, Path: diskPath, Size: int64(len(data)), ChatID: chatID} + if info != nil { + file.UpdatedAt = info.ModTime().UTC().Format("2006-01-02T15:04:05Z") + } + return jsonToolResult(map[string]any{"file": file, "content": content}) + } +} + +func handleTeamsWorkspaceDelete(teams store.TeamStore, cfg *config.Config) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + teamID, err := parseTeamID(req) + if err != nil { + return toolError("teams.workspace.delete", err) + } + fileName, err := req.RequireString("file_name") + if err != nil { + return toolError("teams.workspace.delete", err) + } + if strings.Contains(fileName, "..") || strings.Contains(fileName, "\\") { + return mcpgo.NewToolResultError("teams.workspace.delete: invalid file_name"), nil + } + + chatID := req.GetString("chat_id", "") + if team, err := teams.GetTeam(ctx, teamID); err == nil && tools.IsSharedWorkspace(team.Settings) { + chatID = "" + } else if chatID == "" { + return mcpgo.NewToolResultError("teams.workspace.delete: chat_id is required"), nil + } + + scopeDir := teamWorkspaceDir(ctx, cfg.ResolvedDataDir(), teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, fileName) + if pathErr != nil { + return toolError("teams.workspace.delete", pathErr) + } + if err := os.Remove(diskPath); err != nil { + return mcpgo.NewToolResultError(fmt.Sprintf("teams.workspace.delete: file not found: %s", fileName)), nil + } + return jsonToolResult(map[string]string{"deleted": fileName}) + } +} diff --git a/internal/mcp/crud_test_helpers_test.go b/internal/mcp/crud_test_helpers_test.go new file mode 100644 index 00000000..26298ae3 --- /dev/null +++ b/internal/mcp/crud_test_helpers_test.go @@ -0,0 +1,52 @@ +package mcp + +// crud_test_helpers_test.go provides small shared helpers for invoking +// registered MCP tool handlers directly in tests, without going through the +// HTTP/streamable transport. mcp-go's *mcpserver.MCPServer exposes GetTool() +// which returns the registered ServerTool{Tool, Handler} — calling Handler +// directly is the most faithful way to exercise the exact registration code +// path (tool names, required-arg validation via req.RequireString, etc.) +// without needing a real network listener. + +import ( + "context" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" +) + +// newTestMCPServer builds a bare MCPServer suitable for registering CRUD tool +// families in tests. +func newTestMCPServer() *mcpserver.MCPServer { + return mcpserver.NewMCPServer("goclaw-crud-test", "test", mcpserver.WithToolCapabilities(false)) +} + +// callTool looks up a registered tool by name and invokes its handler with +// the given arguments, failing the test immediately if the tool isn't +// registered. +func callTool(t *testing.T, srv *mcpserver.MCPServer, name string, args map[string]any) *mcpgo.CallToolResult { + t.Helper() + tool := srv.GetTool(name) + if tool == nil { + t.Fatalf("tool %q not registered", name) + } + req := mcpgo.CallToolRequest{} + req.Params.Name = name + req.Params.Arguments = args + result, err := tool.Handler(context.Background(), req) + if err != nil { + t.Fatalf("tool %q handler returned transport error: %v", name, err) + } + return result +} + +// toolResultText extracts the concatenated text content of a tool result. +func toolResultText(result *mcpgo.CallToolResult) string { + return extractTextContent(result) +} + +// toolIsError reports whether result represents an MCP tool-level error. +func toolIsError(result *mcpgo.CallToolResult) bool { + return result != nil && result.IsError +} diff --git a/internal/mcp/crud_usage.go b/internal/mcp/crud_usage.go new file mode 100644 index 00000000..abe45217 --- /dev/null +++ b/internal/mcp/crud_usage.go @@ -0,0 +1,156 @@ +package mcp + +import ( + "context" + "database/sql" + "sort" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// mcpUsageRecord mirrors internal/gateway/methods/usage.go's UsageRecord. +// Cost enrichment (GetSessionCosts via a tracing store) is not wired on this +// standalone MCP surface — see final report. +type mcpUsageRecord struct { + AgentID string `json:"agentId"` + SessionKey string `json:"sessionKey"` + Model string `json:"model"` + Provider string `json:"provider"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + Timestamp int64 `json:"timestamp"` +} + +// extractAgentIDFromSessionKey mirrors internal/gateway/methods/usage.go's +// unexported extractAgentIDFromKey helper. Session keys follow the format +// "agent::". +func extractAgentIDFromSessionKey(key string) string { + const prefix = "agent:" + if len(key) > len(prefix) && key[:len(prefix)] == prefix { + rest := key[len(prefix):] + for i, c := range rest { + if c == ':' { + return rest[:i] + } + } + return rest + } + return key +} + +// registerUsageCRUDTools registers the goclaw_usage_* MCP tools backed by +// store.SessionStore. Mirrors internal/gateway/methods/usage.go. +func registerUsageCRUDTools(srv *mcpserver.MCPServer, sessions store.SessionStore) { + srv.AddTool(mcpgo.NewTool("goclaw_usage_get", + mcpgo.WithDescription("List per-session token usage records, optionally filtered by agent."), + mcpgo.WithString("agent_id", mcpgo.Description("Filter by agent ID.")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum records to return; defaults to 20.")), + mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleUsageGet(sessions)) + + srv.AddTool(mcpgo.NewTool("goclaw_usage_summary", + mcpgo.WithDescription("Return aggregate token usage summary, grouped by agent."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleUsageSummary(sessions)) +} + +func handleUsageGet(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + const defaultLimit = 20 + limit := int(req.GetFloat("limit", 0)) + if limit <= 0 { + limit = defaultLimit + } + offset := int(req.GetFloat("offset", 0)) + + const fetchBatch = 10000 + result := sessions.ListPagedRich(ctx, store.SessionListOpts{ + AgentID: req.GetString("agent_id", ""), + Limit: fetchBatch, + }) + + records := make([]mcpUsageRecord, 0, len(result.Sessions)) + for _, s := range result.Sessions { + if s.InputTokens == 0 && s.OutputTokens == 0 { + continue + } + records = append(records, mcpUsageRecord{ + AgentID: extractAgentIDFromSessionKey(s.Key), SessionKey: s.Key, + Model: s.Model, Provider: s.Provider, + InputTokens: s.InputTokens, OutputTokens: s.OutputTokens, + TotalTokens: s.InputTokens + s.OutputTokens, Timestamp: s.Updated.UnixMilli(), + }) + } + sort.Slice(records, func(i, j int) bool { return records[i].Timestamp > records[j].Timestamp }) + + total := len(records) + start := min(offset, total) + end := min(start+limit, total) + records = records[start:end] + + return jsonToolResult(map[string]any{ + "records": records, "total": total, "limit": limit, "offset": start, + }) + } +} + +func handleUsageSummary(sessions store.SessionStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + const fetchBatch = 10000 + result := sessions.ListPagedRich(ctx, store.SessionListOpts{Limit: fetchBatch}) + + type agentSummary struct { + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + Sessions int `json:"sessions"` + } + byAgent := make(map[string]*agentSummary) + var totalRecords int + for _, s := range result.Sessions { + if s.InputTokens == 0 && s.OutputTokens == 0 { + continue + } + agentID := extractAgentIDFromSessionKey(s.Key) + if byAgent[agentID] == nil { + byAgent[agentID] = &agentSummary{} + } + byAgent[agentID].InputTokens += s.InputTokens + byAgent[agentID].OutputTokens += s.OutputTokens + byAgent[agentID].TotalTokens += s.InputTokens + s.OutputTokens + byAgent[agentID].Sessions++ + totalRecords++ + } + return jsonToolResult(map[string]any{"byAgent": byAgent, "totalRecords": totalRecords}) + } +} + +// registerQuotaCRUDTools registers the goclaw_quota_usage MCP tool backed by +// *channels.QuotaChecker. Mirrors internal/gateway/methods/quota_methods.go. +// Both checker and db may be nil — degrades to {enabled: false} with an +// empty entries list, matching the WS twin's nil-safe contract. +func registerQuotaCRUDTools(srv *mcpserver.MCPServer, checker *channels.QuotaChecker, db *sql.DB) { + srv.AddTool(mcpgo.NewTool("goclaw_quota_usage", + mcpgo.WithDescription("Return per-user/group channel quota consumption for today."), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleQuotaUsage(checker, db)) +} + +func handleQuotaUsage(checker *channels.QuotaChecker, db *sql.DB) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if checker == nil { + result := channels.QuotaUsageResult{Enabled: false, Entries: []channels.QuotaUsageEntry{}} + if db != nil { + channels.QueryTodaySummary(ctx, db, &result) + } + return jsonToolResult(result) + } + return jsonToolResult(checker.Usage(ctx)) + } +} diff --git a/internal/mcp/crud_voices.go b/internal/mcp/crud_voices.go new file mode 100644 index 00000000..171a5b79 --- /dev/null +++ b/internal/mcp/crud_voices.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs" + "github.com/nextlevelbuilder/goclaw/internal/audio/minimax" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const voicesRequestTimeoutMS = 15000 + +// registerVoicesCRUDTools registers goclaw_voices_{list,refresh}, backed by +// the same audio.VoiceCache shared with the gateway's voices.list/refresh WS +// methods (internal/gateway/methods/voices_list.go) and HTTP endpoints +// (internal/http/voices.go). Provider resolution mirrors +// internal/http/voices.go's resolveProvider (duplicated rather than imported: +// internal/http already imports this package for the MCP tool bridge, so +// importing internal/http here would create a cycle) — resolves a per-tenant +// API key from secretStore, defaulting to ElevenLabs. +func registerVoicesCRUDTools(srv *mcpserver.MCPServer, cache *audio.VoiceCache, secretStore store.ConfigSecretsStore) { + srv.AddTool(mcpgo.NewTool("goclaw_voices_list", + mcpgo.WithDescription("List available TTS voices for the caller's tenant (cached)."), + mcpgo.WithString("provider", mcpgo.Description("\"elevenlabs\" (default) or \"minimax\".")), + mcpgo.WithReadOnlyHintAnnotation(true), + ), handleVoicesList(cache, secretStore)) + + srv.AddTool(mcpgo.NewTool("goclaw_voices_refresh", + mcpgo.WithDescription("Invalidate the voice cache and re-fetch from the TTS provider."), + mcpgo.WithString("provider", mcpgo.Description("\"elevenlabs\" (default) or \"minimax\".")), + ), handleVoicesRefresh(cache, secretStore)) +} + +func handleVoicesList(cache *audio.VoiceCache, secretStore store.ConfigSecretsStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if cache == nil { + return mcpgo.NewToolResultError("voices.list: voice cache not available"), nil + } + tenantID := store.TenantIDFromContext(ctx) + if voices, ok := cache.Get(tenantID); ok { + return jsonToolResult(map[string]any{"voices": voices}) + } + p, err := resolveVoiceProvider(ctx, secretStore, tenantID, req.GetString("provider", "")) + if err != nil { + return toolError("voices.list", err) + } + voices, err := p.ListVoices(ctx) + if err != nil { + return toolError("voices.list", err) + } + cache.Set(tenantID, voices) + return jsonToolResult(map[string]any{"voices": voices}) + } +} + +func handleVoicesRefresh(cache *audio.VoiceCache, secretStore store.ConfigSecretsStore) mcpserver.ToolHandlerFunc { + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if cache == nil { + return mcpgo.NewToolResultError("voices.refresh: voice cache not available"), nil + } + tenantID := store.TenantIDFromContext(ctx) + cache.Invalidate(tenantID) + + p, err := resolveVoiceProvider(ctx, secretStore, tenantID, req.GetString("provider", "")) + if err != nil { + return toolError("voices.refresh", err) + } + voices, err := p.ListVoices(ctx) + if err != nil { + return toolError("voices.refresh", err) + } + cache.Set(tenantID, voices) + return jsonToolResult(map[string]any{"voices": voices}) + } +} + +func resolveVoiceProvider(ctx context.Context, secretStore store.ConfigSecretsStore, tenantID uuid.UUID, providerName string) (audio.VoiceListProvider, error) { + if secretStore == nil { + return nil, fmt.Errorf("no voice provider configured") + } + if providerName == "" { + providerName = "elevenlabs" + } + + switch providerName { + case "minimax": + apiKey, err := secretStore.Get(ctx, "tts.minimax.api_key") + if err != nil || apiKey == "" { + return nil, fmt.Errorf("MiniMax API key not found for tenant %s", tenantID) + } + apiBase, _ := secretStore.Get(ctx, "tts.minimax.api_base") + return minimax.NewVoiceLister(apiKey, apiBase, voicesRequestTimeoutMS, tenantID), nil + case "elevenlabs": + apiKey, err := secretStore.Get(ctx, "tts.elevenlabs.api_key") + if err != nil || apiKey == "" { + return nil, fmt.Errorf("ElevenLabs API key not found for tenant %s", tenantID) + } + return elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: apiKey}), nil + default: + return nil, fmt.Errorf("unsupported voice provider: %s", providerName) + } +} diff --git a/internal/mcp/crud_voices_test.go b/internal/mcp/crud_voices_test.go new file mode 100644 index 00000000..666f6036 --- /dev/null +++ b/internal/mcp/crud_voices_test.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/nextlevelbuilder/goclaw/internal/audio" +) + +// fakeSecretsStore is a minimal in-memory store.ConfigSecretsStore. +type fakeSecretsStore struct { + values map[string]string +} + +func newFakeSecretsStore() *fakeSecretsStore { + return &fakeSecretsStore{values: map[string]string{}} +} + +func (f *fakeSecretsStore) Get(_ context.Context, key string) (string, error) { + return f.values[key], nil +} +func (f *fakeSecretsStore) Set(_ context.Context, key, value string) error { + f.values[key] = value + return nil +} +func (f *fakeSecretsStore) Delete(_ context.Context, key string) error { + delete(f.values, key) + return nil +} +func (f *fakeSecretsStore) GetAll(_ context.Context) (map[string]string, error) { + return f.values, nil +} + +const testVoiceCacheTTL = time.Minute + +func TestVoicesList_NilCache(t *testing.T) { + srv := newTestMCPServer() + registerVoicesCRUDTools(srv, nil, nil) + + result := callTool(t, srv, "goclaw_voices_list", map[string]any{}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "voice cache not available") +} + +func TestVoicesList_NoAPIKeyConfigured(t *testing.T) { + cache := audio.NewVoiceCache(testVoiceCacheTTL, 10) + secrets := newFakeSecretsStore() + srv := newTestMCPServer() + registerVoicesCRUDTools(srv, cache, secrets) + + result := callTool(t, srv, "goclaw_voices_list", map[string]any{}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "API key not found") +} + +func TestVoicesList_UnsupportedProvider(t *testing.T) { + cache := audio.NewVoiceCache(testVoiceCacheTTL, 10) + secrets := newFakeSecretsStore() + srv := newTestMCPServer() + registerVoicesCRUDTools(srv, cache, secrets) + + result := callTool(t, srv, "goclaw_voices_list", map[string]any{"provider": "unsupported-tts"}) + assert.True(t, toolIsError(result)) + assert.Contains(t, toolResultText(result), "unsupported voice provider") +} + +func TestVoicesRefresh_NilCache(t *testing.T) { + srv := newTestMCPServer() + registerVoicesCRUDTools(srv, nil, nil) + + result := callTool(t, srv, "goclaw_voices_refresh", map[string]any{}) + assert.True(t, toolIsError(result)) + require.Contains(t, toolResultText(result), "voice cache not available") +} diff --git a/internal/pipeline/observe_stage.go b/internal/pipeline/observe_stage.go index ab19366c..2e5bbd10 100644 --- a/internal/pipeline/observe_stage.go +++ b/internal/pipeline/observe_stage.go @@ -2,8 +2,13 @@ package pipeline import ( "context" + "log/slog" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/hooks" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // ObserveStage runs per iteration after ToolStage. Drains InjectCh, @@ -56,6 +61,47 @@ func (s *ObserveStage) drainInjectedMessages() []providers.Message { func (s *ObserveStage) observeFinalResponse(state *RunState, resp *providers.ChatResponse, injected []providers.Message) { content, thinking := splitTaggedThinkingContent(resp.Content, resp.Thinking) + + // Fire post_model_response hook for final responses (no tool calls). + // This is a blocking hook that can prevent user delivery and inject a retry. + if s.deps.Hooks != nil && len(injected) == 0 { + ev := hooks.Event{ + EventID: uuid.NewString(), + SessionID: state.Input.SessionKey, + TenantID: store.TenantIDFromContext(state.Ctx), + AgentID: store.AgentIDFromContext(state.Ctx), + HookEvent: hooks.EventPostModelResponse, + ModelResponse: content, + Thinking: thinking, + ToolCalls: resp.ToolCalls, + } + result, err := s.deps.FireHook(state.Ctx, ev) + if err != nil { + slog.Debug("post_model_response hook error", "error", err) + } + if result.Decision == hooks.DecisionBlock { + // Hook blocked delivery: keep assistant message in history, inject + // rejection reason as user message, and continue to next iteration. + state.Messages.AppendPending(providers.Message{ + Role: "assistant", + Content: content, + Thinking: thinking, + }) + rejectionMsg := result.DecisionReason + if rejectionMsg == "" { + rejectionMsg = "Response blocked by policy." + } + state.Messages.AppendPending(providers.Message{ + Role: "user", + Content: rejectionMsg, + }) + state.Observe.BlockedByHook = true + state.Observe.HookRejectionReason = rejectionMsg + state.Observe.ContinueAfterFinal = true + return + } + } + if len(injected) == 0 { state.Observe.FinalContent = content state.Observe.FinalThinking = thinking diff --git a/internal/pipeline/substates.go b/internal/pipeline/substates.go index 1a840b3e..c9a6203e 100644 --- a/internal/pipeline/substates.go +++ b/internal/pipeline/substates.go @@ -83,6 +83,10 @@ type ObserveState struct { // in iter N and responds text-only in iter N+1, reading only LastResponse.Images // would lose the image. AssistantImages []providers.ImageContent + + // Post-model-response hook blocking. + BlockedByHook bool // true if post_model_response hook blocked delivery + HookRejectionReason string // rejection reason from hook, injected as user message } // CompactState: owned by CheckpointStage + MemoryFlushStage. diff --git a/internal/skills/seeder.go b/internal/skills/seeder.go index c18e20ec..533e7636 100644 --- a/internal/skills/seeder.go +++ b/internal/skills/seeder.go @@ -414,6 +414,39 @@ func CopyDir(src, dst string) error { }) } +// HashDir computes a content hash and total size for all regular files under +// dir (skipping directories and symlinks). Used to detect skill content +// changes after writing a new version directory. +func HashDir(dir string) (string, int64, error) { + var size int64 + h := sha256.New() + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + size += info.Size() + rel, _ := filepath.Rel(dir, path) + h.Write([]byte(filepath.ToSlash(rel))) + data, err := os.ReadFile(path) + if err != nil { + return err + } + h.Write(data) + return nil + }) + if err != nil { + return "", 0, err + } + return fmt.Sprintf("%x", h.Sum(nil)), size, nil +} + // needsReCopy returns true when the managed copy's scripts/ is missing or has fewer // entries than the bundled source — symptom of a previous failed copy caused by a // symlink-to-directory stopping filepath.Walk early (e.g. scripts/office/ symlink). diff --git a/internal/skills/write_file.go b/internal/skills/write_file.go new file mode 100644 index 00000000..c0c24099 --- /dev/null +++ b/internal/skills/write_file.go @@ -0,0 +1,125 @@ +package skills + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// Errors returned by WriteVersionedFile, mapped by callers (HTTP handler, +// MCP tool) to their own status codes. +var ( + ErrSkillFileNotFound = errors.New("skill file not found") + ErrSkillIsSystem = errors.New("cannot edit a system skill") + ErrSkillInvalidPath = errors.New("invalid file path") +) + +// WriteVersionedFile writes relPath's content into a new immutable version of +// a managed (non-system) skill: it copies the current version directory, +// writes the file into the copy, atomically renames it into place, and +// repoints the skill's DB row at the new version. Historical versions remain +// immutable. Shared by internal/http's skill-editor endpoint +// (SkillsHandler.handleWriteFile) and the goclaw_skills_write_file MCP tool +// so both surfaces apply identical validation and versioning. +func WriteVersionedFile(ctx context.Context, manage store.SkillManageStore, tenantSkillsDir string, id uuid.UUID, relPath, content string) (path string, version int, err error) { + if strings.Contains(relPath, "..") { + return "", 0, ErrSkillInvalidPath + } + + filePath, slug, currentVersion, isSystem, ok := manage.GetSkillFilePath(ctx, id) + if !ok { + return "", 0, ErrSkillFileNotFound + } + if isSystem { + return "", 0, ErrSkillIsSystem + } + + slugDir := store.SkillSlugDir(filePath) + if slugDir == "" { + return "", 0, ErrSkillFileNotFound + } + currentDir := filepath.Join(slugDir, strconv.Itoa(currentVersion)) + if info, statErr := os.Stat(currentDir); statErr != nil || !info.IsDir() { + return "", 0, ErrSkillFileNotFound + } + + cleanRelPath := filepath.Clean(relPath) + // Validate the path against the CURRENT version directory before staging + // a copy — cheaper failure path and keeps the escape/symlink checks close + // to the original request path. + checkPath := filepath.Join(currentDir, cleanRelPath) + if !strings.HasPrefix(checkPath, currentDir+string(filepath.Separator)) { + return "", 0, ErrSkillInvalidPath + } + if fi, lstatErr := os.Lstat(checkPath); lstatErr == nil { + if fi.Mode()&os.ModeSymlink != 0 || fi.IsDir() { + return "", 0, ErrSkillInvalidPath + } + } + if IsSystemArtifact(filepath.Base(cleanRelPath)) { + return "", 0, ErrSkillInvalidPath + } + + // Create a new immutable version: lock the next version number, stage a + // copy of the current version directory, write the edited file into the + // staged copy, then atomically rename it into place and repoint the + // skill's DB row. + newVersion, commitLock, err := manage.GetNextVersionLocked(ctx, slug) + if err != nil { + return "", 0, err + } + defer commitLock() //nolint:errcheck + + destDir := filepath.Join(tenantSkillsDir, slug, strconv.Itoa(newVersion)) + tmpDir := destDir + ".tmp-" + uuid.NewString() + if err := CopyDir(currentDir, tmpDir); err != nil { + return "", 0, err + } + removeDestOnError := true + defer func() { + _ = os.RemoveAll(tmpDir) + if removeDestOnError { + _ = os.RemoveAll(destDir) + } + }() + + absPath := filepath.Join(tmpDir, cleanRelPath) + if !strings.HasPrefix(absPath, tmpDir+string(filepath.Separator)) { + return "", 0, ErrSkillInvalidPath + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return "", 0, err + } + if err := os.WriteFile(absPath, []byte(content), 0o644); err != nil { + return "", 0, err + } + if err := os.Rename(tmpDir, destDir); err != nil { + return "", 0, err + } + + hash, size, err := HashDir(destDir) + if err != nil { + return "", 0, err + } + if err := manage.UpdateSkill(ctx, id, map[string]any{ + "version": newVersion, + "file_path": destDir, + "file_size": size, + "file_hash": &hash, + "updated_at": time.Now(), + }); err != nil { + return "", 0, err + } + removeDestOnError = false + + manage.BumpVersion() + return relPath, newVersion, nil +} diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index c35e7b13..7ee8040f 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -445,7 +445,12 @@ func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentD argIdx++ } - if clause, targs, _, err := scopeClause(ctx, argIdx); err == nil && clause != "" { + clause, targs, _, err := scopeClause(ctx, argIdx) + if err != nil { + slog.Warn("agents.List: tenant context missing, returning empty (fail-closed)", "error", err) + return nil, nil + } + if clause != "" { q += clause args = append(args, targs...) argIdx++ diff --git a/internal/store/pg/agents_list_tenant_scope_test.go b/internal/store/pg/agents_list_tenant_scope_test.go new file mode 100644 index 00000000..e86e5b03 --- /dev/null +++ b/internal/store/pg/agents_list_tenant_scope_test.go @@ -0,0 +1,42 @@ +package pg + +import ( + "context" + "testing" +) + +// TestPGAgentStoreList_NilTenant_FailsClosed verifies List returns an empty +// result (not an unscoped cross-tenant leak) when the incoming context +// carries no tenant scope — matching the fail-closed contract already +// enforced by GetByID/GetByKey/CronStore.ListJobs/TeamStore.ListTeams. +// Regression test for the bug where List silently swallowed scopeClause's +// error and ran the query unscoped. +func TestPGAgentStoreList_NilTenant_FailsClosed(t *testing.T) { + db := hooksTestDB(t) + tenantID, agentID := seedTenantAndAgent(t, db) + // seedTenantAndAgent leaves display_name NULL, which scanAgentRow can't + // scan into a plain string — set it so List's underlying scan succeeds. + if _, err := db.Exec(`UPDATE agents SET display_name = 'test agent' WHERE id = $1`, agentID); err != nil { + t.Fatalf("set display_name: %v", err) + } + agentStore := NewPGAgentStore(db) + + // Sanity check: the seeded agent IS visible when properly tenant-scoped. + scoped, err := agentStore.List(tenantScopedCtx(tenantID), "") + if err != nil { + t.Fatalf("List (tenant-scoped): %v", err) + } + if len(scoped) == 0 { + t.Fatal("expected at least one agent when tenant-scoped, got none") + } + + // No tenant in context at all (uuid.Nil, not master, not cross-tenant): + // must fail closed (empty, no error), never return unscoped rows. + unscoped, err := agentStore.List(context.Background(), "") + if err != nil { + t.Fatalf("List (no tenant context): unexpected error %v", err) + } + if len(unscoped) != 0 { + t.Fatalf("List (no tenant context) leaked %d rows across tenants, want 0 (fail-closed)", len(unscoped)) + } +} diff --git a/internal/store/sqlitestore/agents.go b/internal/store/sqlitestore/agents.go index 6d19b549..ba93485e 100644 --- a/internal/store/sqlitestore/agents.go +++ b/internal/store/sqlitestore/agents.go @@ -231,10 +231,12 @@ func (s *SQLiteAgentStore) List(ctx context.Context, ownerID string) ([]store.Ag if !store.IsCrossTenant(ctx) { tid := store.TenantIDFromContext(ctx) - if tid != uuid.Nil { - q += " AND tenant_id = ?" - args = append(args, tid) + if tid == uuid.Nil { + slog.Warn("agents.List: tenant context missing, returning empty (fail-closed)") + return nil, nil } + q += " AND tenant_id = ?" + args = append(args, tid) } q += " ORDER BY created_at DESC" diff --git a/internal/store/sqlitestore/agents_list_tenant_scope_test.go b/internal/store/sqlitestore/agents_list_tenant_scope_test.go new file mode 100644 index 00000000..a13c83ef --- /dev/null +++ b/internal/store/sqlitestore/agents_list_tenant_scope_test.go @@ -0,0 +1,43 @@ +//go:build sqlite || sqliteonly + +package sqlitestore + +import ( + "context" + "testing" +) + +// TestSQLiteAgentStoreList_NilTenant_FailsClosed verifies List returns an +// empty result (not an unscoped cross-tenant leak) when the incoming context +// carries no tenant scope — matching the fail-closed contract already +// enforced by GetByID/GetByKey. Regression test for the bug where List +// silently omitted the tenant_id filter whenever the context tenant was nil. +func TestSQLiteAgentStoreList_NilTenant_FailsClosed(t *testing.T) { + db := newHookTestDB(t) + tenantID, agentID := seedHookTenantAgent(t, db) + // seedHookTenantAgent leaves display_name NULL, which scanAgentRow can't + // scan into a plain string — set it so List's underlying scan succeeds. + if _, err := db.Exec(`UPDATE agents SET display_name = 'test agent' WHERE id = ?`, agentID.String()); err != nil { + t.Fatalf("set display_name: %v", err) + } + agentStore := NewSQLiteAgentStore(db) + + // Sanity check: the seeded agent IS visible when properly tenant-scoped. + scoped, err := agentStore.List(sqliteTenantCtx(tenantID), "") + if err != nil { + t.Fatalf("List (tenant-scoped): %v", err) + } + if len(scoped) == 0 { + t.Fatal("expected at least one agent when tenant-scoped, got none") + } + + // No tenant in context at all (uuid.Nil, not master, not cross-tenant): + // must fail closed (empty, no error), never return unscoped rows. + unscoped, err := agentStore.List(context.Background(), "") + if err != nil { + t.Fatalf("List (no tenant context): unexpected error %v", err) + } + if len(unscoped) != 0 { + t.Fatalf("List (no tenant context) leaked %d rows across tenants, want 0 (fail-closed)", len(unscoped)) + } +} diff --git a/internal/webui/handler.go b/internal/webui/handler.go index ffb4da65..905babb2 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -14,7 +14,7 @@ import ( // apiPrefixes are URL prefixes reserved for backend APIs. // Requests matching these are never served by the SPA handler. -var apiPrefixes = []string{"/v1/", "/ws", "/health", "/mcp/"} +var apiPrefixes = []string{"/v1/", "/ws", "/health", "/api/mcp/"} // Handler returns an http.Handler that serves the embedded SPA. // Returns nil if no assets are embedded (built without embedui tag). diff --git a/ui/web/src/hooks/use-hooks.ts b/ui/web/src/hooks/use-hooks.ts index 075ceaba..2466df30 100644 --- a/ui/web/src/hooks/use-hooks.ts +++ b/ui/web/src/hooks/use-hooks.ts @@ -200,7 +200,9 @@ export function useTestHook() { sampleEvent, }: { config: Partial; - sampleEvent: { toolName: string; toolInput: Record; rawInput?: string }; + sampleEvent: + | { toolName: string; toolInput: Record; rawInput?: string } + | { modelResponse: Record; thinking?: string; toolCalls?: Array>; rawInput?: string }; }) => ws.call<{ result: HookTestResult }>("hooks.test", { config, sampleEvent }), onError: (err) => { diff --git a/ui/web/src/i18n/locales/en/hooks.json b/ui/web/src/i18n/locales/en/hooks.json index 53dff00b..c110d495 100644 --- a/ui/web/src/i18n/locales/en/hooks.json +++ b/ui/web/src/i18n/locales/en/hooks.json @@ -8,7 +8,7 @@ "badge": "Beta", "description": "Hooks let you intercept agent lifecycle events to allow, block, or rewrite what happens. Currently in beta — expect rough edges and watch your audit log.", "howItWorksTitle1": "1. Pick an event", - "howItWorksBody1": "Each hook fires on one event such as user_prompt_submit, pre_tool_use, or stop. Blocking events wait for your decision before continuing.", + "howItWorksBody1": "Each hook fires on one event such as user_prompt_submit, pre_tool_use, post_model_response, or stop. Blocking events wait for your decision before continuing.", "howItWorksTitle2": "2. Choose a handler", "howItWorksBody2": "Script runs sandboxed JavaScript. HTTP posts to your webhook. Prompt asks an LLM. Built-in hooks (System badge) ship with GoClaw and only the enabled toggle is editable.", "howItWorksTitle3": "3. Test before enabling", @@ -104,7 +104,7 @@ "resultHint": "What the dispatcher returned, including any input mutations from builtin hooks.", "emptyState": "Run the hook to see how it would respond.", "fire": "Run hook", - "firing": "Running\u2026", + "firing": "Running…", "decision": "Decision", "duration": "Duration", "reason": "Reason", @@ -113,7 +113,13 @@ "statusCode": "Status code", "updatedInput": "Updated input (diff)", "toolNamePickerPlaceholder": "Select or type tool name...", - "overwriteConfirm": "Current input will be replaced with template. Continue?" + "overwriteConfirm": "Current input will be replaced with template. Continue?", + "modelResponse": "Model Response", + "modelResponseHint": "The model's response content (JSON). Used by post_model_response hooks.", + "thinking": "Thinking", + "thinkingHint": "The model's chain-of-thought reasoning (optional).", + "toolCalls": "Tool Calls", + "toolCallsHint": "Array of tool calls the model wants to make (optional)." }, "history": { "title": "Execution history", @@ -146,4 +152,4 @@ "error": "Error", "timeout": "Timeout" } -} +} \ No newline at end of file diff --git a/ui/web/src/i18n/locales/vi/hooks.json b/ui/web/src/i18n/locales/vi/hooks.json index 15f20882..b2c8e10c 100644 --- a/ui/web/src/i18n/locales/vi/hooks.json +++ b/ui/web/src/i18n/locales/vi/hooks.json @@ -8,7 +8,7 @@ "badge": "Beta", "description": "Hooks cho phép bạn chen vào các sự kiện vòng đời của agent để cho phép, chặn hoặc viết lại nội dung. Đang trong giai đoạn beta — có thể còn lỗi, hãy theo dõi audit log.", "howItWorksTitle1": "1. Chọn sự kiện", - "howItWorksBody1": "Mỗi hook gắn với một sự kiện như user_prompt_submit, pre_tool_use, hoặc stop. Sự kiện blocking sẽ chờ quyết định của bạn trước khi tiếp tục.", + "howItWorksBody1": "Mỗi hook gắn với một sự kiện như user_prompt_submit, pre_tool_use, post_model_response, hoặc stop. Sự kiện blocking sẽ chờ quyết định của bạn trước khi tiếp tục.", "howItWorksTitle2": "2. Chọn loại xử lý", "howItWorksBody2": "Script chạy JavaScript trong sandbox. HTTP gọi webhook của bạn. Prompt hỏi LLM. Hook dựng sẵn (badge Hệ thống) do GoClaw cung cấp — chỉ chỉnh được toggle bật/tắt.", "howItWorksTitle3": "3. Test trước khi bật", @@ -104,7 +104,7 @@ "resultHint": "Phản hồi từ dispatcher, bao gồm cả mutation đầu vào từ hook builtin.", "emptyState": "Bấm Run để xem hook phản hồi thế nào.", "fire": "Chạy hook", - "firing": "Đang chạy\u2026", + "firing": "Đang chạy…", "decision": "Quyết định", "duration": "Thời gian", "reason": "Lý do", @@ -113,7 +113,13 @@ "statusCode": "Mã trạng thái", "updatedInput": "Đầu vào đã cập nhật (diff)", "toolNamePickerPlaceholder": "Chọn hoặc nhập tên tool...", - "overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?" + "overwriteConfirm": "Nội dung hiện tại sẽ bị thay thế bằng mẫu. Tiếp tục?", + "modelResponse": "Phản hồi Model", + "modelResponseHint": "Nội dung phản hồi của model (JSON). Dùng cho hook post_model_response.", + "thinking": "Quá trình suy nghĩ", + "thinkingHint": "Chuỗi suy luận của model (tùy chọn).", + "toolCalls": "Các cuộc gọi Tool", + "toolCallsHint": "Mảng các cuộc gọi tool mà model muốn thực hiện (tùy chọn)." }, "history": { "title": "Lịch sử thực thi", @@ -146,4 +152,4 @@ "error": "Lỗi", "timeout": "Hết thời gian" } -} +} \ No newline at end of file diff --git a/ui/web/src/i18n/locales/zh/hooks.json b/ui/web/src/i18n/locales/zh/hooks.json index 74bd209a..c66d64e1 100644 --- a/ui/web/src/i18n/locales/zh/hooks.json +++ b/ui/web/src/i18n/locales/zh/hooks.json @@ -8,7 +8,7 @@ "badge": "Beta", "description": "钩子让你拦截 Agent 生命周期事件以放行、阻止或改写其行为。当前处于公测阶段 — 可能存在问题,请关注审计日志。", "howItWorksTitle1": "1. 选择事件", - "howItWorksBody1": "每个钩子绑定一个事件,例如 user_prompt_submit、pre_tool_use 或 stop。阻塞型事件会等待你的决策后再继续。", + "howItWorksBody1": "每个钩子绑定一个事件,例如 user_prompt_submit、pre_tool_use、post_model_response 或 stop。阻塞型事件会等待你的决策后再继续。", "howItWorksTitle2": "2. 选择处理器", "howItWorksBody2": "Script 在沙箱中运行 JavaScript。HTTP 调用你的 Webhook。Prompt 询问 LLM。内置钩子(系统徽章)由 GoClaw 提供 — 仅可切换启用状态。", "howItWorksTitle3": "3. 启用前先测试", @@ -104,7 +104,7 @@ "resultHint": "调度器的返回结果,包括来自内置钩子的输入修改。", "emptyState": "运行钩子以查看响应。", "fire": "运行钩子", - "firing": "运行中\u2026", + "firing": "运行中…", "decision": "决策", "duration": "耗时", "reason": "原因", @@ -113,7 +113,13 @@ "statusCode": "状态码", "updatedInput": "已更新输入(差异)", "toolNamePickerPlaceholder": "选择或输入工具名称...", - "overwriteConfirm": "当前内容将被模板替换。继续?" + "overwriteConfirm": "当前内容将被模板替换。继续?", + "modelResponse": "模型响应", + "modelResponseHint": "模型的响应内容(JSON)。用于 post_model_response 钩子。", + "thinking": "思考过程", + "thinkingHint": "模型的思维链推理(可选)。", + "toolCalls": "工具调用", + "toolCallsHint": "模型想要进行的工具调用数组(可选)。" }, "history": { "title": "执行历史", @@ -146,4 +152,4 @@ "error": "错误", "timeout": "超时" } -} +} \ No newline at end of file diff --git a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx index 8fd8f369..935297a2 100644 --- a/ui/web/src/pages/hooks/components/hook-form-dialog.tsx +++ b/ui/web/src/pages/hooks/components/hook-form-dialog.tsx @@ -20,7 +20,7 @@ import { ScriptEditor } from "./script-editor"; const HOOK_EVENTS = [ "session_start", "user_prompt_submit", "pre_tool_use", - "post_tool_use", "stop", "subagent_start", "subagent_stop", + "post_tool_use", "post_model_response", "stop", "subagent_start", "subagent_stop", ] as const; interface HookFormDialogProps { diff --git a/ui/web/src/pages/hooks/components/hook-test-panel.tsx b/ui/web/src/pages/hooks/components/hook-test-panel.tsx index caa0cd0d..236f61a5 100644 --- a/ui/web/src/pages/hooks/components/hook-test-panel.tsx +++ b/ui/web/src/pages/hooks/components/hook-test-panel.tsx @@ -47,12 +47,23 @@ export function HookTestPanel({ hook }: HookTestPanelProps) { const { t } = useTranslation("hooks"); const testMutation = useTestHook(); + const isModelResponse = hook.event === "post_model_response"; const saved = loadSavedSample(hook.id); const [toolName, setToolName] = useState(saved?.toolName ?? "bash"); const [toolInputRaw, setToolInputRaw] = useState( saved?.toolInputRaw ?? JSON.stringify({ command: "ls -la" }, null, 2), ); const [rawInput, setRawInput] = useState(saved?.rawInput ?? ""); + // post_model_response specific fields + const [modelResponse, setModelResponse] = useState( + saved?.modelResponse ?? JSON.stringify({ content: "Task completed successfully." }, null, 2), + ); + const [thinking, setThinking] = useState( + saved?.thinking ?? "The user asked to list files. I'll use the bash tool.", + ); + const [toolCallsRaw, setToolCallsRaw] = useState( + saved?.toolCallsRaw ?? JSON.stringify([{ name: "bash", arguments: { command: "ls -la" } }], null, 2), + ); const [result, setResult] = useState(null); const [parseError, setParseError] = useState(null); @@ -69,18 +80,33 @@ export function HookTestPanel({ hook }: HookTestPanelProps) { const handleFire = async () => { setParseError(null); let toolInput: Record; + let toolCalls: Array> | undefined; try { - toolInput = JSON.parse(toolInputRaw); + if (isModelResponse) { + toolInput = JSON.parse(modelResponse); + toolCalls = JSON.parse(toolCallsRaw); + } else { + toolInput = JSON.parse(toolInputRaw); + } } catch { setParseError(t("test.parseError")); return; } - saveSample(hook.id, { toolName, toolInputRaw, rawInput }); + saveSample(hook.id, { + toolName, + toolInputRaw, + rawInput, + modelResponse, + thinking, + toolCallsRaw, + }); const res = await testMutation.mutateAsync({ config: hook, - sampleEvent: { toolName, toolInput, rawInput: rawInput || undefined }, + sampleEvent: isModelResponse + ? { modelResponse: toolInput, thinking, toolCalls, rawInput: rawInput || undefined } + : { toolName, toolInput, rawInput: rawInput || undefined }, }); setResult(res.result); }; @@ -96,43 +122,85 @@ export function HookTestPanel({ hook }: HookTestPanelProps) { -
- - -

{t("test.toolNameHint")}

-
+ {isModelResponse ? ( + <> +
+ +