(feat) MCP Server (#1365)

* feat(hooks/observe): add structured output validation hook (ObserveHook)

- hooks/types.go: Add ObserveHook interface + BuiltInHookType enum
- hooks/dispatcher.go: HookDispatcher emits ObserveHook with PhaseResult payload
- pipeline/observe_stage.go: ObserveStage emits hook after ObserveResult built
- pipeline/substates.go: ObserveStageResult carries hook results + validation errors
- hooks/config.go: BuiltInHookTypeObserve added to BuiltInHookType enum

PhaseResult carries structured output, token usage, tool calls + validation errors
emitted post-ObserveStage. ObserveHook implementations can validate structured
output against schemas, detect tool-call loops, enforce token budgets, etc.

Hook fires after ObserveStage produces ObserveResult, before results propagate
to next stage. ValidationError returned by hook halts pipeline and propagates
error to caller without further stage execution.

Co-Authored-By: Claude <noreply@anthropic.com>

* ui(hooks): add post_model_response event to web UI

- Add event to Zod schema, filter dropdown, and form dialog
- Implement conditional test panel UI for model response payload
- Add translations (en/zh/vi) for new test panel fields
- Updated beta description to reference the new event

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(mcp): add MCP CRUD server exposing goclaw resources at /api/mcp/ with Bearer token auth
and X-GoClaw-Tenant-Id header, default to master tenant

* feat(mcp): add goclaw_skills_write_file tool to edit skill files on disk

The CRUD MCP server's goclaw_skills_update only touched skill DB metadata,
with no way to edit a skill's SKILL.md/file content on the filesystem. Extract
the versioned write logic from the web UI's skill file editor
(SkillsHandler.handleWriteFile) into skills.WriteVersionedFile so both
surfaces share identical validation and versioning, and expose it as a new
MCP tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-15 09:56:43 +07:00
1 parent 4f558e2c3b
commit 293aa2a5e1
75 files changed
+8461 -166

No files matched your search

+40
View File
@@ -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 {
+3
View File
@@ -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.
+3
View File
@@ -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)
+1
View File
@@ -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)
+1
View File
@@ -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)
+5
View File
@@ -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)
+138
View File
@@ -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
}
+63
View File
@@ -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)
}
@@ -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)
}
}
+203
View File
@@ -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)
+1
View File
@@ -35,6 +35,7 @@ var knownEvents = map[HookEvent]struct{}{
EventStop: {},
EventSubagentStart: {},
EventSubagentStop: {},
EventPostModelResponse: {},
}
// Validate checks a HookConfig for semantic correctness and fills in defaults.
+6 -6
View File
@@ -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
}
}
+18
View File
@@ -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)
+15 -4
View File
@@ -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
}
+14 -98
View File
@@ -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 {
+164
View File
@@ -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})
}
}
+448
View File
@@ -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,
})
}
}
+131
View File
@@ -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")
}
+124
View File
@@ -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"})
}
}
+145
View File
@@ -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
}
+281
View File
@@ -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"})
}
}
+90
View File
@@ -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")
}
+177
View File
@@ -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)
}
}
+96
View File
@@ -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))
}
+28
View File
@@ -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())
}
}
+180
View File
@@ -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})
}
}
+22
View File
@@ -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")
}
+269
View File
@@ -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()})
}
}
+92
View File
@@ -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")
}
+80
View File
@@ -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"})
}
}
+860
View File
@@ -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
}
+341
View File
@@ -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})
}
}
+104
View File
@@ -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")
}
+164
View File
@@ -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)
}
+81
View File
@@ -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) }
+307
View File
@@ -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",
})
}
}
+97
View File
@@ -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")
}
+148
View File
@@ -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")
}
+89
View File
@@ -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")
}
+77
View File
@@ -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})
}
}
+175
View File
@@ -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"})
}
}
+65
View File
@@ -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")
}
+45
View File
@@ -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,
})
}
}
+54
View File
@@ -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,
})
}
}
+231
View File
@@ -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)
}),
)
}
+149
View File
@@ -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)
}
}
+176
View File
@@ -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})
}
}
+108
View File
@@ -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`)
}
+179
View File
@@ -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})
}
}
+78
View File
@@ -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))
}
+345
View File
@@ -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
}
+430
View File
@@ -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})
}
}
+106
View File
@@ -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))
}
+260
View File
@@ -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})
}
}
+52
View File
@@ -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
}
+156
View File
@@ -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:<agentID>:<scopeKey>".
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))
}
}
+109
View File
@@ -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)
}
}
+78
View File
@@ -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")
}
+46
View File
@@ -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
+4
View File
@@ -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.
+33
View File
@@ -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).
+125
View File
@@ -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
}
+6 -1
View File
@@ -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++
@@ -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))
}
}
+5 -3
View File
@@ -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"
@@ -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))
}
}
+1 -1
View File
@@ -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).
+3 -1
View File
@@ -200,7 +200,9 @@ export function useTestHook() {
sampleEvent,
}: {
config: Partial<HookConfig>;
sampleEvent: { toolName: string; toolInput: Record<string, unknown>; rawInput?: string };
sampleEvent:
| { toolName: string; toolInput: Record<string, unknown>; rawInput?: string }
| { modelResponse: Record<string, unknown>; thinking?: string; toolCalls?: Array<Record<string, unknown>>; rawInput?: string };
}) =>
ws.call<{ result: HookTestResult }>("hooks.test", { config, sampleEvent }),
onError: (err) => {
+10 -4
View File
@@ -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"
}
}
}
+10 -4
View File
@@ -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"
}
}
}
+10 -4
View File
@@ -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": "超时"
}
}
}
@@ -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 {
@@ -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<string>(saved?.toolName ?? "bash");
const [toolInputRaw, setToolInputRaw] = useState<string>(
saved?.toolInputRaw ?? JSON.stringify({ command: "ls -la" }, null, 2),
);
const [rawInput, setRawInput] = useState<string>(saved?.rawInput ?? "");
// post_model_response specific fields
const [modelResponse, setModelResponse] = useState<string>(
saved?.modelResponse ?? JSON.stringify({ content: "Task completed successfully." }, null, 2),
);
const [thinking, setThinking] = useState<string>(
saved?.thinking ?? "The user asked to list files. I'll use the bash tool.",
);
const [toolCallsRaw, setToolCallsRaw] = useState<string>(
saved?.toolCallsRaw ?? JSON.stringify([{ name: "bash", arguments: { command: "ls -la" } }], null, 2),
);
const [result, setResult] = useState<HookTestResult | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
@@ -69,18 +80,33 @@ export function HookTestPanel({ hook }: HookTestPanelProps) {
const handleFire = async () => {
setParseError(null);
let toolInput: Record<string, unknown>;
let toolCalls: Array<Record<string, unknown>> | 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) {
</div>
</header>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolName")}</Label>
<ToolSingleCombobox
value={toolName}
onChange={setToolName}
onToolSelect={handleToolSelect}
placeholder={t("test.toolNamePickerPlaceholder")}
/>
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
</div>
{isModelResponse ? (
<>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.modelResponse")}</Label>
<Textarea
value={modelResponse}
onChange={(e) => setModelResponse(e.target.value)}
rows={6}
placeholder='{"content": "Task completed successfully."}'
className="text-base md:text-sm font-mono"
/>
<p className="text-2xs text-muted-foreground">{t("test.modelResponseHint")}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolInput")}</Label>
<Textarea
value={toolInputRaw}
onChange={(e) => setToolInputRaw(e.target.value)}
rows={10}
placeholder='{"command": "ls -la"}'
className="text-base md:text-sm font-mono"
/>
{parseError ? (
<p className="text-xs text-destructive">{parseError}</p>
) : (
<p className="text-2xs text-muted-foreground">{t("test.toolInputHint")}</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.thinking")}</Label>
<Textarea
value={thinking}
onChange={(e) => setThinking(e.target.value)}
rows={4}
placeholder="The user asked to list files. I'll use the bash tool."
className="text-base md:text-sm font-mono"
/>
<p className="text-2xs text-muted-foreground">{t("test.thinkingHint")}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.rawInput")}</Label>
<Input
value={rawInput}
onChange={(e) => setRawInput(e.target.value)}
placeholder={t("test.rawInputPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-2xs text-muted-foreground">{t("test.rawInputHint")}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolCalls")}</Label>
<Textarea
value={toolCallsRaw}
onChange={(e) => setToolCallsRaw(e.target.value)}
rows={4}
placeholder='[{"name": "bash", "arguments": {"command": "ls -la"}}]'
className="text-base md:text-sm font-mono"
/>
<p className="text-2xs text-muted-foreground">{t("test.toolCallsHint")}</p>
</div>
</>
) : (
<>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolName")}</Label>
<ToolSingleCombobox
value={toolName}
onChange={setToolName}
onToolSelect={handleToolSelect}
placeholder={t("test.toolNamePickerPlaceholder")}
/>
<p className="text-2xs text-muted-foreground">{t("test.toolNameHint")}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.toolInput")}</Label>
<Textarea
value={toolInputRaw}
onChange={(e) => setToolInputRaw(e.target.value)}
rows={10}
placeholder='{"command": "ls -la"}'
className="text-base md:text-sm font-mono"
/>
{parseError ? (
<p className="text-xs text-destructive">{parseError}</p>
) : (
<p className="text-2xs text-muted-foreground">{t("test.toolInputHint")}</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("test.rawInput")}</Label>
<Input
value={rawInput}
onChange={(e) => setRawInput(e.target.value)}
placeholder={t("test.rawInputPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-2xs text-muted-foreground">{t("test.rawInputHint")}</p>
</div>
</>
)}
<Button
onClick={handleFire}
+1 -1
View File
@@ -27,7 +27,7 @@ import type { HookFormData } from "@/schemas/hooks.schema";
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;
// parseHeaders accepts an empty string, an empty object string, or a JSON
+1
View File
@@ -5,6 +5,7 @@ export const HookEventEnum = z.enum([
"user_prompt_submit",
"pre_tool_use",
"post_tool_use",
"post_model_response",
"stop",
"subagent_start",
"subagent_stop",