feat(mcp): expand CRUD server to near-complete CLI parity (#1440)

* feat(mcp): expand CRUD MCP server to near-complete CLI parity

Closes the CLI-vs-MCP tool coverage gap identified by auditing every
`goclaw` CLI command against the existing goclaw_* MCP tool set. Adds
skill grant/revoke, agent skill pin/unpin, and full CRUD/inspection
surfaces for memory, knowledge graph (including dedup/merge/prune),
tenants, providers, LLM traces, channel contacts, pending messages,
audit activity, system config, tenant storage (list/size/delete/move),
scoped agent config export/import, secure-CLI binary registry, and a
DB-backed health check.

Deliberately out of scope, documented inline where relevant:
- `goclaw credentials`: confirmed CLI-local (~/.goclaw/config.yaml +
  keychain), no server resource to wrap. goclaw_secure_cli_binaries_*
  covers the closest real, previously-uncovered server resource instead.
- Full tar-archive agent export/import (KG + workspace files): the CLI's
  version streams a multi-section archive with progress events, a shape
  that doesn't map to a single MCP tool call. Config + context files
  (the portable "brain") is covered.
- `kg extract` (LLM-driven text extraction): goclaw_kg_ingest accepts
  the same Entity/Relation shapes the extractor produces, so a caller
  can run extraction itself and hand off the result.

Wires 8 new store dependencies (Memory, KnowledgeGraph, Tracing,
Contacts, PendingMessages, Activity, SystemConfigs, SecureCLI) through
gateway.Server setters -> cmd/gateway.go -> CRUDDeps, following the
existing Providers/Tenants pattern. Storage and secure-CLI-binary
handlers duplicate internal/http's path-escape/symlink-hiding
validation logic (documented inline) since internal/http already
imports internal/mcp and the reverse would cycle.

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

* fix(mcp): return sessionKey in goclaw_chat_send response for persistent conversations

The goclaw_chat_send tool creates a new session internally when sessionKey is
empty, but never returned the key to the caller. This prevented using
goclaw_chat_history to fetch previous messages in the session.

Add SessionKey field to ChatSendResult so callers can:
1. Start a new agent chat without providing sessionKey
2. Receive the sessionKey back in the response
3. Use that sessionKey for follow-up messages and history queries

Fixes the training loop pattern: start chat → get sessionKey → call
goclaw_chat_history with that key → iterate skill based on actual failures.

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

* feat(mcp): add timing diagnostics to goclaw_chat_send for timeout troubleshooting

Log request arrival, processing duration, and errors with millisecond precision.
Helps identify whether timeouts occur at MCP client→goclaw, goclaw→ollama, or
during agent execution. Critical for production debugging.

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

* fix(gateway): add http.Server timeouts for defensive timeout handling

Set explicit timeouts in http.Server:
- ReadTimeout: 1h (allow large uploads, long-running agent operations)
- WriteTimeout: 1h (allow streaming responses to slow clients)
- IdleTimeout: 30s (close idle keep-alive connections quickly)

Provides defense-in-depth when Nginx/Traefik timeouts are misconfigured.
Matches Nginx timeout (3600s) to prevent race conditions.

Timeout chain: traefik 3600s = nginx 3600s = goclaw 3600s

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

---------

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-07-16 01:56:03 +07:00
1 parent 02249593a2
commit dc605b911e
23 files changed
+2774 -49

No files matched your search

+24
View File
@@ -538,6 +538,30 @@ func runGateway() {
if pgStores.Tenants != nil {
server.SetTenantStore(pgStores.Tenants)
}
if pgStores.Memory != nil {
server.SetMemoryStore(pgStores.Memory)
}
if pgStores.KnowledgeGraph != nil {
server.SetKnowledgeGraphStore(pgStores.KnowledgeGraph)
}
if pgStores.Tracing != nil {
server.SetTracingStore(pgStores.Tracing)
}
if pgStores.Contacts != nil {
server.SetContactStore(pgStores.Contacts)
}
if pgStores.PendingMessages != nil {
server.SetPendingMessageStore(pgStores.PendingMessages)
}
if pgStores.Activity != nil {
server.SetActivityStore(pgStores.Activity)
}
if pgStores.SystemConfigs != nil {
server.SetSystemConfigStore(pgStores.SystemConfigs)
}
if pgStores.SecureCLI != nil {
server.SetSecureCLIStore(pgStores.SecureCLI)
}
server.SetSQLDB(pgStores.DB)
// Build OAuth token refresher before wireExtras so the resolver can inject tokens.
+7 -6
View File
@@ -67,17 +67,18 @@ func (r *agentChatRunner) Send(ctx context.Context, agentID, sessionKey, message
})
if err != nil {
if ctx.Err() != nil {
return &mcpbridge.ChatSendResult{Cancelled: true}, nil
return &mcpbridge.ChatSendResult{Cancelled: true, SessionKey: sessionKey}, 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,
RunID: result.RunID,
SessionKey: sessionKey,
Content: result.Content,
Usage: result.Usage,
Thinking: result.Thinking,
Media: result.Media,
}, nil
}
+56 -2
View File
@@ -77,6 +77,14 @@ type Server struct {
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
memoryStore store.MemoryStore
kgStore store.KnowledgeGraphStore
tracingStore store.TracingStore
contactStore store.ContactStore
pendingMsgStore store.PendingMessageStore
activityStore store.ActivityStore
systemCfgStore store.SystemConfigStore
secureCLIStore store.SecureCLIStore
// Phase 3 CRUD MCP server (/api/mcp/) dependencies: chat/LLM/logs/send/voices.
llmProviders *providers.Registry
@@ -317,6 +325,14 @@ func (s *Server) BuildMux() *http.ServeMux {
VoiceCache: s.voiceCache,
VoiceSecretsStore: s.voiceSecretsStore,
Tenants: s.tenantStore,
Memory: s.memoryStore,
KnowledgeGraph: s.kgStore,
Tracing: s.tracingStore,
Contacts: s.contactStore,
PendingMessages: s.pendingMsgStore,
Activity: s.activityStore,
SystemConfigs: s.systemCfgStore,
SecureCLI: s.secureCLIStore,
}, s.version)
mux.Handle("/api/mcp/", mcpServerTokenAuthMiddleware(s.cfg.Gateway.MCPServerToken, crudHandler))
} else {
@@ -496,8 +512,11 @@ func (s *Server) Start(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
s.httpServer = &http.Server{
Addr: addr,
Handler: handler,
Addr: addr,
Handler: handler,
ReadTimeout: 3600 * time.Second, // 1h: allow large uploads, long-running reads
WriteTimeout: 3600 * time.Second, // 1h: allow streaming responses, slow clients
IdleTimeout: 30 * time.Second, // 30s: close idle connections
}
slog.Info("gateway starting", "addr", addr)
@@ -867,6 +886,41 @@ func (s *Server) SetProviderStore(ps store.ProviderStore) { s.providerStore = ps
// request header (UUID or slug) to a concrete tenant for every CRUD MCP call.
func (s *Server) SetTenantStore(ts store.TenantStore) { s.tenantStore = ts }
// SetMemoryStore sets the memory store, used by the CRUD MCP server (see
// internal/mcp/crud_server.go) to expose goclaw_memory_* tools.
func (s *Server) SetMemoryStore(ms store.MemoryStore) { s.memoryStore = ms }
// SetKnowledgeGraphStore sets the knowledge graph store, used by the CRUD
// MCP server (see internal/mcp/crud_server.go) to expose goclaw_kg_* tools.
func (s *Server) SetKnowledgeGraphStore(kg store.KnowledgeGraphStore) { s.kgStore = kg }
// SetTracingStore sets the LLM call tracing store, used by the CRUD MCP
// server (see internal/mcp/crud_server.go) to expose goclaw_traces_* tools.
func (s *Server) SetTracingStore(ts store.TracingStore) { s.tracingStore = ts }
// SetContactStore sets the channel contact store, used by the CRUD MCP
// server (see internal/mcp/crud_server.go) to expose goclaw_contacts_* tools.
func (s *Server) SetContactStore(cs store.ContactStore) { s.contactStore = cs }
// SetPendingMessageStore sets the pending-message store, used by the CRUD
// MCP server (see internal/mcp/crud_server.go) to expose
// goclaw_pending_messages_* tools.
func (s *Server) SetPendingMessageStore(pm store.PendingMessageStore) { s.pendingMsgStore = pm }
// SetActivityStore sets the audit-log store, used by the CRUD MCP server
// (see internal/mcp/crud_server.go) to expose goclaw_activity_list.
func (s *Server) SetActivityStore(as store.ActivityStore) { s.activityStore = as }
// SetSystemConfigStore sets the system config store, used by the CRUD MCP
// server (see internal/mcp/crud_server.go) to expose goclaw_system_config_*
// tools.
func (s *Server) SetSystemConfigStore(sc store.SystemConfigStore) { s.systemCfgStore = sc }
// SetSecureCLIStore sets the secure-CLI binary registry store, used by the
// CRUD MCP server (see internal/mcp/crud_server.go) to expose
// goclaw_secure_cli_binaries_* tools.
func (s *Server) SetSecureCLIStore(sc store.SecureCLIStore) { s.secureCLIStore = sc }
// 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 }
+51
View File
@@ -0,0 +1,51 @@
package mcp
import (
"context"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// registerActivityCRUDTools registers goclaw_activity_list, backed by
// store.ActivityStore — closes a CLI-vs-MCP coverage gap (`goclaw activity
// list`, the audit log of admin/agent actions emitted via emitAudit
// throughout internal/http).
func registerActivityCRUDTools(srv *mcpserver.MCPServer, activity store.ActivityStore) {
srv.AddTool(mcpgo.NewTool("goclaw_activity_list",
mcpgo.WithDescription("List audit log entries (admin/agent actions), optionally filtered."),
mcpgo.WithString("actor_type", mcpgo.Description("Filter by actor type (e.g. \"user\", \"agent\", \"system\").")),
mcpgo.WithString("actor_id", mcpgo.Description("Filter by actor ID.")),
mcpgo.WithString("action", mcpgo.Description("Filter by action name (e.g. \"skill.file_updated\").")),
mcpgo.WithString("entity_type", mcpgo.Description("Filter by entity type (e.g. \"skill\", \"agent\").")),
mcpgo.WithString("entity_id", mcpgo.Description("Filter by entity ID.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return; defaults to 50.")),
mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleActivityList(activity))
}
func handleActivityList(activity store.ActivityStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
opts := store.ActivityListOpts{
ActorType: req.GetString("actor_type", ""),
ActorID: req.GetString("actor_id", ""),
Action: req.GetString("action", ""),
EntityType: req.GetString("entity_type", ""),
EntityID: req.GetString("entity_id", ""),
Limit: intArg(req, "limit", 50),
Offset: intArg(req, "offset", 0),
}
list, err := activity.List(ctx, opts)
if err != nil {
return toolError("activity.list", err)
}
total, err := activity.Count(ctx, opts)
if err != nil {
return toolError("activity.list", err)
}
return jsonToolResult(map[string]any{"entries": list, "total": total})
}
}
+141
View File
@@ -2,6 +2,7 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
@@ -446,3 +447,143 @@ func handleAgentsFilesSet(agents store.AgentStore) mcpserver.ToolHandlerFunc {
})
}
}
// maxPinnedSkillsPerAgent mirrors the invariant documented on
// store.AgentData.ParsePinnedSkills (agent_store.go) — pinned skills are
// always-loaded into every turn's system prompt, so the count is capped to
// bound prompt size.
const maxPinnedSkillsPerAgent = 10
// registerAgentSkillPinCRUDTools registers goclaw_agents_pin_skill/
// goclaw_agents_unpin_skill. Pinning is distinct from granting access
// (registerSkillGrantCRUDTools in crud_skills.go): a pinned skill is
// auto-loaded into the agent's system prompt every turn (see
// internal/agent/resolver.go's ParsePinnedSkills / PinnedSkillsSummary),
// while a grant only makes the skill available for on-demand use_skill
// calls. There is no dedicated pin/unpin RPC on the gateway — the web UI
// sets other_config.pinned_skills via the general agents.update WS method
// (internal/gateway/methods/agents_update.go), which replaces the whole
// other_config JSONB blob wholesale. These handlers replicate that by
// reading the agent's current other_config, splicing pinned_skills, and
// writing the full blob back — a naive partial write would silently drop
// every other other_config field (self_evolution_metrics, tts_params, etc).
func registerAgentSkillPinCRUDTools(srv *mcpserver.MCPServer, agents store.AgentStore) {
srv.AddTool(mcpgo.NewTool("goclaw_agents_pin_skill",
mcpgo.WithDescription("Pin a skill onto an agent so it's auto-loaded into that agent's system prompt every turn (distinct from granting access)."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")),
mcpgo.WithString("skill", mcpgo.Required(), mcpgo.Description("Skill slug/name to pin.")),
), handleAgentsPinSkill(agents))
srv.AddTool(mcpgo.NewTool("goclaw_agents_unpin_skill",
mcpgo.WithDescription("Unpin a skill from an agent (does not revoke access, only removes it from the always-loaded set)."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")),
mcpgo.WithString("skill", mcpgo.Required(), mcpgo.Description("Skill slug/name to unpin.")),
), handleAgentsUnpinSkill(agents))
}
// spliceOtherConfigPinnedSkills reads ag's other_config JSONB, applies edit
// to its pinned_skills list, and returns the full re-marshaled blob ready
// to pass as agents.Update's "other_config" value (a full-blob replace, not
// a merge — see registerAgentSkillPinCRUDTools doc comment).
func spliceOtherConfigPinnedSkills(ag store.AgentData, edit func(current []string) ([]string, error)) (json.RawMessage, error) {
bag := map[string]any{}
if len(ag.OtherConfig) > 0 {
if err := json.Unmarshal(ag.OtherConfig, &bag); err != nil {
return nil, fmt.Errorf("cannot parse existing other_config: %w", err)
}
}
next, err := edit(ag.ParsePinnedSkills())
if err != nil {
return nil, err
}
bag["pinned_skills"] = next
return json.Marshal(bag)
}
func handleAgentsPinSkill(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.pin_skill", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("agents.pin_skill", fmt.Errorf("invalid id: %w", err))
}
skill, err := req.RequireString("skill")
if err != nil {
return toolError("agents.pin_skill", err)
}
ag, err := agents.GetByID(ctx, id)
if err != nil {
return toolError("agents.pin_skill", err)
}
raw, err := spliceOtherConfigPinnedSkills(*ag, func(current []string) ([]string, error) {
if slices.Contains(current, skill) {
return current, nil
}
if len(current) >= maxPinnedSkillsPerAgent {
return nil, fmt.Errorf("agent already has %d pinned skills (max %d)", len(current), maxPinnedSkillsPerAgent)
}
return append(current, skill), nil
})
if err != nil {
return toolError("agents.pin_skill", err)
}
if err := agents.Update(ctx, id, map[string]any{"other_config": []byte(raw)}); err != nil {
return toolError("agents.pin_skill", err)
}
updated, err := agents.GetByID(ctx, id)
if err != nil {
return toolError("agents.pin_skill", err)
}
return jsonToolResult(map[string]any{"ok": "true", "pinned_skills": updated.ParsePinnedSkills()})
}
}
func handleAgentsUnpinSkill(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.unpin_skill", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("agents.unpin_skill", fmt.Errorf("invalid id: %w", err))
}
skill, err := req.RequireString("skill")
if err != nil {
return toolError("agents.unpin_skill", err)
}
ag, err := agents.GetByID(ctx, id)
if err != nil {
return toolError("agents.unpin_skill", err)
}
raw, err := spliceOtherConfigPinnedSkills(*ag, func(current []string) ([]string, error) {
out := make([]string, 0, len(current))
for _, s := range current {
if s != skill {
out = append(out, s)
}
}
return out, nil
})
if err != nil {
return toolError("agents.unpin_skill", err)
}
if err := agents.Update(ctx, id, map[string]any{"other_config": []byte(raw)}); err != nil {
return toolError("agents.unpin_skill", err)
}
updated, err := agents.GetByID(ctx, id)
if err != nil {
return toolError("agents.unpin_skill", err)
}
return jsonToolResult(map[string]any{"ok": "true", "pinned_skills": updated.ParsePinnedSkills()})
}
}
+141
View File
@@ -0,0 +1,141 @@
package mcp
import (
"context"
"encoding/json"
"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"
)
// remarshalInto converts a generic decoded-JSON map into a typed struct via
// a JSON round-trip — used to parse the "config" argument (a nested object
// in the MCP tool call) into store.AgentData without hand-mapping every
// field.
func remarshalInto(src map[string]any, dst any) error {
raw, err := json.Marshal(src)
if err != nil {
return err
}
return json.Unmarshal(raw, dst)
}
// registerAgentExportCRUDTools registers goclaw_agents_export/
// goclaw_agents_import, partially closing the `goclaw export agent`/`import
// agent` CLI-vs-MCP coverage gap. Scoped to agent config + context files
// (SOUL.md, IDENTITY.md, etc.) as a JSON payload — the CLI's full export is
// a multi-section tar archive (config, context files, knowledge graph
// entities, workspace files; see internal/http/agents_export_archive.go)
// built for streaming HTTP download with progress events, a shape that
// doesn't translate to a single MCP tool call. Config + context files is
// the commonly-needed portable subset (an agent's "brain"); KG/workspace
// portability is not covered here.
func registerAgentExportCRUDTools(srv *mcpserver.MCPServer, agents store.AgentStore) {
srv.AddTool(mcpgo.NewTool("goclaw_agents_export",
mcpgo.WithDescription("Export an agent's config and context files (SOUL.md, IDENTITY.md, etc.) as a JSON snapshot. Does not include knowledge graph or workspace files — see tool description for the full CLI export's scope."),
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),
), handleAgentsExport(agents))
srv.AddTool(mcpgo.NewTool("goclaw_agents_import",
mcpgo.WithDescription("Create a new agent from a goclaw_agents_export snapshot. Always creates a new agent (never overwrites) — pass a new agent_key to avoid collision, or omit to auto-dedup the exported key."),
mcpgo.WithObject("config", mcpgo.Required(), mcpgo.Description("The \"config\" object from a goclaw_agents_export snapshot.")),
mcpgo.WithObject("context_files", mcpgo.Description("The \"context_files\" object from a goclaw_agents_export snapshot (file name -> content).")),
mcpgo.WithString("agent_key", mcpgo.Description("Override agent_key for the new agent; defaults to the exported agent_key.")),
mcpgo.WithString("owner_id", mcpgo.Description("Owner user ID; defaults to \"system\".")),
), handleAgentsImport(agents))
}
func handleAgentsExport(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", "")
var ag *store.AgentData
var err error
switch {
case idStr != "":
id, parseErr := uuid.Parse(idStr)
if parseErr != nil {
return toolError("agents.export", fmt.Errorf("invalid id: %w", parseErr))
}
ag, err = agents.GetByID(ctx, id)
case agentKey != "":
ag, err = agents.GetByKey(ctx, agentKey)
default:
return mcpgo.NewToolResultError("agents.export: one of id or agent_key is required"), nil
}
if err != nil {
return toolError("agents.export", err)
}
dbFiles, err := agents.GetAgentContextFiles(ctx, ag.ID)
if err != nil {
return toolError("agents.export", err)
}
files := make(map[string]string, len(dbFiles))
for _, f := range dbFiles {
files[f.FileName] = f.Content
}
return jsonToolResult(map[string]any{"config": ag, "context_files": files})
}
}
func handleAgentsImport(agents store.AgentStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
args := req.GetArguments()
rawConfig, ok := args["config"].(map[string]any)
if !ok || len(rawConfig) == 0 {
return mcpgo.NewToolResultError("agents.import: config is required"), nil
}
var ag store.AgentData
if err := remarshalInto(rawConfig, &ag); err != nil {
return toolError("agents.import", fmt.Errorf("cannot parse config: %w", err))
}
// Always create a new agent — never overwrite an existing one via import.
ag.ID = store.GenNewID()
ag.CreatedAt = time.Time{}
ag.UpdatedAt = time.Time{}
if agentKey := req.GetString("agent_key", ""); agentKey != "" {
ag.AgentKey = agentKey
}
if ag.AgentKey == "" {
return mcpgo.NewToolResultError("agents.import: config.agent_key is required (or pass agent_key)"), nil
}
ag.OwnerID = req.GetString("owner_id", "system")
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = store.MasterTenantID
}
ag.TenantID = tenantID
if err := agents.Create(ctx, &ag); err != nil {
return toolError("agents.import", err)
}
filesWritten := 0
if rawFiles, ok := args["context_files"].(map[string]any); ok {
for name, v := range rawFiles {
content, ok := v.(string)
if !ok || !isAllowedAgentContextFile(name) {
continue
}
if err := agents.SetAgentContextFile(ctx, ag.ID, name, content); err == nil {
filesWritten++
}
}
}
return jsonToolResult(map[string]any{"id": ag.ID.String(), "agent_key": ag.AgentKey, "files_written": filesWritten})
}
}
+11
View File
@@ -2,6 +2,8 @@ package mcp
import (
"context"
"log/slog"
"time"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
@@ -62,10 +64,19 @@ func handleChatSend(runner ChatRunner) mcpserver.ToolHandlerFunc {
}
agentID := req.GetString("agent_id", "")
sessionKey := req.GetString("session_key", "")
startTime := time.Now()
slog.Info("mcp.chat_send.start", "agent_id", agentID, "session_key", sessionKey, "message_len", len(message))
result, err := runner.Send(ctx, agentID, sessionKey, message, nil)
duration := time.Since(startTime)
if err != nil {
slog.Warn("mcp.chat_send.error", "agent_id", agentID, "session_key", sessionKey, "duration_ms", duration.Milliseconds(), "error", err)
return toolError("chat.send", err)
}
slog.Info("mcp.chat_send.done", "agent_id", agentID, "session_key", sessionKey, "run_id", result.RunID, "duration_ms", duration.Milliseconds(), "content_len", len(result.Content))
return jsonToolResult(result)
}
}
+146
View File
@@ -0,0 +1,146 @@
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"
)
// registerContactsCRUDTools registers the goclaw_contacts_* MCP tools backed
// by store.ContactStore — closes a CLI-vs-MCP coverage gap (the `goclaw
// channels contacts create/list/verify` commands had no MCP equivalent).
// Contacts are auto-collected from channel traffic (see internal/store
// ContactCollector), so there is no create tool here — only inspection and
// identity merge/unmerge, mirroring internal/http/contact_merge_handlers.go.
func registerContactsCRUDTools(srv *mcpserver.MCPServer, contacts store.ContactStore) {
srv.AddTool(mcpgo.NewTool("goclaw_contacts_list",
mcpgo.WithDescription("List/search channel contacts (auto-collected user info from channel traffic)."),
mcpgo.WithString("search", mcpgo.Description("Search text (matches display name, username, sender ID).")),
mcpgo.WithString("channel_type", mcpgo.Description("Filter by platform (telegram, discord, etc.).")),
mcpgo.WithString("channel_instance", mcpgo.Description("Filter by channel instance name.")),
mcpgo.WithString("peer_kind", mcpgo.Description("Filter by \"direct\" or \"group\".")),
mcpgo.WithString("contact_type", mcpgo.Description("Filter by \"user\" or \"group\".")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum contacts to return; defaults to 50.")),
mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleContactsList(contacts))
srv.AddTool(mcpgo.NewTool("goclaw_contacts_get",
mcpgo.WithDescription("Get a single contact by UUID."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Contact UUID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleContactsGet(contacts))
srv.AddTool(mcpgo.NewTool("goclaw_contacts_merge",
mcpgo.WithDescription("Merge one or more contacts into a single tenant-user identity."),
mcpgo.WithArray("contact_ids", mcpgo.Required(), mcpgo.Description("Contact UUIDs to merge.")),
mcpgo.WithString("tenant_user_id", mcpgo.Required(), mcpgo.Description("Tenant-user UUID to merge into.")),
), handleContactsMerge(contacts))
srv.AddTool(mcpgo.NewTool("goclaw_contacts_unmerge",
mcpgo.WithDescription("Unmerge contacts, unlinking them from their tenant-user identity."),
mcpgo.WithArray("contact_ids", mcpgo.Required(), mcpgo.Description("Contact UUIDs to unmerge.")),
), handleContactsUnmerge(contacts))
}
func handleContactsList(contacts store.ContactStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
opts := store.ContactListOpts{
Search: req.GetString("search", ""),
ChannelType: req.GetString("channel_type", ""),
ChannelInstance: req.GetString("channel_instance", ""),
PeerKind: req.GetString("peer_kind", ""),
ContactType: req.GetString("contact_type", ""),
Limit: intArg(req, "limit", 50),
Offset: intArg(req, "offset", 0),
}
list, err := contacts.ListContacts(ctx, opts)
if err != nil {
return toolError("contacts.list", err)
}
total, err := contacts.CountContacts(ctx, opts)
if err != nil {
return toolError("contacts.list", err)
}
return jsonToolResult(map[string]any{"contacts": list, "total": total})
}
}
func handleContactsGet(contacts store.ContactStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("contacts.get", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("contacts.get", fmt.Errorf("invalid id: %w", err))
}
contact, err := contacts.GetContactByID(ctx, id)
if err != nil {
return toolError("contacts.get", err)
}
return jsonToolResult(contact)
}
}
// parseUUIDArray parses a required array argument as a list of UUIDs.
func parseUUIDArray(req mcpgo.CallToolRequest, key string) ([]uuid.UUID, error) {
raw, ok := req.GetArguments()[key].([]any)
if !ok || len(raw) == 0 {
return nil, fmt.Errorf("%s is required and must be a non-empty array", key)
}
out := make([]uuid.UUID, 0, len(raw))
for _, v := range raw {
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("%s: all elements must be strings", key)
}
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("%s: invalid UUID %q: %w", key, s, err)
}
out = append(out, id)
}
return out, nil
}
func handleContactsMerge(contacts store.ContactStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
contactIDs, err := parseUUIDArray(req, "contact_ids")
if err != nil {
return toolError("contacts.merge", err)
}
tenantUserIDStr, err := req.RequireString("tenant_user_id")
if err != nil {
return toolError("contacts.merge", err)
}
tenantUserID, err := uuid.Parse(tenantUserIDStr)
if err != nil {
return toolError("contacts.merge", fmt.Errorf("invalid tenant_user_id: %w", err))
}
if err := contacts.MergeContacts(ctx, contactIDs, tenantUserID); err != nil {
return toolError("contacts.merge", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleContactsUnmerge(contacts store.ContactStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
contactIDs, err := parseUUIDArray(req, "contact_ids")
if err != nil {
return toolError("contacts.unmerge", err)
}
if err := contacts.UnmergeContacts(ctx, contactIDs); err != nil {
return toolError("contacts.unmerge", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+1 -1
View File
@@ -836,7 +836,7 @@ func (f *fakeChatRunner) Send(_ context.Context, agentID, sessionKey, message st
if f.sendResult != nil {
return f.sendResult, nil
}
return &ChatSendResult{RunID: "run-1", Content: "ok"}, nil
return &ChatSendResult{RunID: "run-1", SessionKey: sessionKey, Content: "ok"}, nil
}
func (f *fakeChatRunner) Abort(_ context.Context, _, _ string) (*ChatAbortResult, error) {
+42
View File
@@ -0,0 +1,42 @@
package mcp
import (
"context"
"database/sql"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// registerHealthCRUDTool registers goclaw_health, closing the `goclaw
// health`/`status` CLI-vs-MCP coverage gap. A successful MCP tool call
// already proves the gateway is reachable and this bearer token is valid —
// what it can't prove on its own is that the DB behind it is up, so this
// tool's only real value-add over "the call succeeded" is the DB ping.
func registerHealthCRUDTool(srv *mcpserver.MCPServer, db *sql.DB, version string) {
srv.AddTool(mcpgo.NewTool("goclaw_health",
mcpgo.WithDescription("Check gateway health: protocol version, server version, and database connectivity."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleHealth(db, version))
}
func handleHealth(db *sql.DB, version string) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
result := map[string]any{
"status": "ok",
"protocol": protocol.ProtocolVersion,
"version": version,
}
if db != nil {
if err := db.PingContext(ctx); err != nil {
result["status"] = "degraded"
result["db_error"] = err.Error()
} else {
result["db"] = "ok"
}
}
return jsonToolResult(result)
}
}
+7 -6
View File
@@ -103,12 +103,13 @@ type ChatMediaItem struct {
// 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"`
RunID string `json:"runId"`
SessionKey string `json:"sessionKey"`
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
+466
View File
@@ -0,0 +1,466 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// registerKnowledgeGraphCRUDTools registers the goclaw_kg_* MCP tools backed
// by store.KnowledgeGraphStore — closes the `goclaw kg` CLI-vs-MCP coverage
// gap: read/inspect (entities/search/traverse/relations/stats), direct
// writes (link = upsert relation, entity upsert/delete), and the dedup
// family (scan/list/merge/dismiss). "extract" itself (running the LLM-driven
// extraction pipeline over free text) is intentionally not wrapped here —
// internal/knowledgegraph's extractor needs a resolved LLM provider/model
// and produces the same Entity/Relation shapes this surface already accepts
// via goclaw_kg_ingest, so a caller can run extraction itself (e.g. via
// goclaw_llm_complete) and hand the result to goclaw_kg_ingest.
func registerKnowledgeGraphCRUDTools(srv *mcpserver.MCPServer, kg store.KnowledgeGraphStore) {
srv.AddTool(mcpgo.NewTool("goclaw_kg_entities_list",
mcpgo.WithDescription("List knowledge graph entities for an agent/user scope."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("entity_type", mcpgo.Description("Filter by entity type.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum entities to return.")),
mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGEntitiesList(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_entity_get",
mcpgo.WithDescription("Get a single knowledge graph entity by ID."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("entity_id", mcpgo.Required(), mcpgo.Description("Entity ID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGEntityGet(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_search",
mcpgo.WithDescription("Search knowledge graph entities by name/description."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("query", mcpgo.Required(), mcpgo.Description("Search query.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum results to return.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGSearch(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_traverse",
mcpgo.WithDescription("Traverse the knowledge graph outward from a starting entity."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("entity_id", mcpgo.Required(), mcpgo.Description("Starting entity ID.")),
mcpgo.WithNumber("max_depth", mcpgo.Description("Maximum traversal depth; defaults to 2.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGTraverse(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_relations_list",
mcpgo.WithDescription("List relations for an entity, or all relations for the scope if entity_id is omitted."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("entity_id", mcpgo.Description("Entity ID; omit to list all relations in scope.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum results when entity_id is omitted.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGRelationsList(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_stats",
mcpgo.WithDescription("Get aggregate knowledge graph stats (entity/relation counts by type) for an agent/user scope."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGStats(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_ingest",
mcpgo.WithDescription("Upsert a batch of entities and relations (e.g. output of an LLM extraction pass) into the knowledge graph."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithArray("entities", mcpgo.Description("Entities to upsert (objects matching the Entity shape from goclaw_kg_entities_list).")),
mcpgo.WithArray("relations", mcpgo.Description("Relations to upsert (objects matching the Relation shape from goclaw_kg_relations_list).")),
), handleKGIngest(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_entity_delete",
mcpgo.WithDescription("Delete a single knowledge graph entity."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("entity_id", mcpgo.Required(), mcpgo.Description("Entity ID to delete.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleKGEntityDelete(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_relation_delete",
mcpgo.WithDescription("Delete a single knowledge graph relation."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("relation_id", mcpgo.Required(), mcpgo.Description("Relation ID to delete.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleKGRelationDelete(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_prune",
mcpgo.WithDescription("Delete entities below a confidence threshold."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithNumber("min_confidence", mcpgo.Required(), mcpgo.Description("Entities with confidence below this value are deleted.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleKGPrune(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_dedup_scan",
mcpgo.WithDescription("Scan all entities with embeddings for near-duplicates, flagging candidates above a similarity threshold for review."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithNumber("threshold", mcpgo.Description("Similarity threshold (0-1); defaults to 0.90.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum candidates to flag; defaults to 100.")),
), handleKGDedupScan(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_dedup_candidates",
mcpgo.WithDescription("List pending dedup candidates for review."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum candidates to return; defaults to 50.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleKGDedupCandidates(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_merge_entities",
mcpgo.WithDescription("Merge one entity into another: relations are re-pointed to the target and the source entity is deleted."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global entities.")),
mcpgo.WithString("target_id", mcpgo.Required(), mcpgo.Description("Entity ID to keep.")),
mcpgo.WithString("source_id", mcpgo.Required(), mcpgo.Description("Entity ID to merge into target and delete.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleKGMergeEntities(kg))
srv.AddTool(mcpgo.NewTool("goclaw_kg_dismiss_candidate",
mcpgo.WithDescription("Dismiss a dedup candidate as not a duplicate."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("candidate_id", mcpgo.Required(), mcpgo.Description("Dedup candidate ID.")),
), handleKGDismissCandidate(kg))
}
func handleKGEntitiesList(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.entities_list", err)
}
userID := req.GetString("user_id", "")
opts := store.EntityListOptions{
EntityType: req.GetString("entity_type", ""),
Limit: intArg(req, "limit", 0),
Offset: intArg(req, "offset", 0),
}
entities, err := kg.ListEntities(ctx, agentID, userID, opts)
if err != nil {
return toolError("kg.entities_list", err)
}
return jsonToolResult(entities)
}
}
func handleKGEntityGet(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.entity_get", err)
}
entityID, err := req.RequireString("entity_id")
if err != nil {
return toolError("kg.entity_get", err)
}
userID := req.GetString("user_id", "")
entity, err := kg.GetEntity(ctx, agentID, userID, entityID)
if err != nil {
return toolError("kg.entity_get", err)
}
return jsonToolResult(entity)
}
}
func handleKGSearch(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.search", err)
}
query, err := req.RequireString("query")
if err != nil {
return toolError("kg.search", err)
}
userID := req.GetString("user_id", "")
limit := intArg(req, "limit", 20)
entities, err := kg.SearchEntities(ctx, agentID, userID, query, limit)
if err != nil {
return toolError("kg.search", err)
}
return jsonToolResult(entities)
}
}
func handleKGTraverse(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.traverse", err)
}
entityID, err := req.RequireString("entity_id")
if err != nil {
return toolError("kg.traverse", err)
}
userID := req.GetString("user_id", "")
maxDepth := intArg(req, "max_depth", 2)
results, err := kg.Traverse(ctx, agentID, userID, entityID, maxDepth)
if err != nil {
return toolError("kg.traverse", err)
}
return jsonToolResult(results)
}
}
func handleKGRelationsList(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.relations_list", err)
}
userID := req.GetString("user_id", "")
entityID := req.GetString("entity_id", "")
if entityID != "" {
relations, err := kg.ListRelations(ctx, agentID, userID, entityID)
if err != nil {
return toolError("kg.relations_list", err)
}
return jsonToolResult(relations)
}
limit := intArg(req, "limit", 100)
relations, err := kg.ListAllRelations(ctx, agentID, userID, limit)
if err != nil {
return toolError("kg.relations_list", err)
}
return jsonToolResult(relations)
}
}
func handleKGStats(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.stats", err)
}
userID := req.GetString("user_id", "")
stats, err := kg.Stats(ctx, agentID, userID)
if err != nil {
return toolError("kg.stats", err)
}
return jsonToolResult(stats)
}
}
// intArg reads an integer-valued argument (MCP numbers decode as float64),
// returning fallback when absent.
func intArg(req mcpgo.CallToolRequest, key string, fallback int) int {
if v, ok := req.GetArguments()[key]; ok {
if f, ok := v.(float64); ok {
return int(f)
}
}
return fallback
}
// remarshalEntities/remarshalRelations parse a generic JSON array argument
// into typed slices via a JSON round-trip (same technique as
// remarshalInto in crud_agents_export.go).
func remarshalEntities(raw any) ([]store.Entity, error) {
if raw == nil {
return nil, nil
}
var out []store.Entity
data, err := json.Marshal(raw)
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}
func remarshalRelations(raw any) ([]store.Relation, error) {
if raw == nil {
return nil, nil
}
var out []store.Relation
data, err := json.Marshal(raw)
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}
func handleKGIngest(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.ingest", err)
}
userID := req.GetString("user_id", "")
args := req.GetArguments()
entities, err := remarshalEntities(args["entities"])
if err != nil {
return toolError("kg.ingest", fmt.Errorf("invalid entities: %w", err))
}
relations, err := remarshalRelations(args["relations"])
if err != nil {
return toolError("kg.ingest", fmt.Errorf("invalid relations: %w", err))
}
if len(entities) == 0 && len(relations) == 0 {
return mcpgo.NewToolResultError("kg.ingest: at least one of entities or relations is required"), nil
}
ids, err := kg.IngestExtraction(ctx, agentID, userID, entities, relations)
if err != nil {
return toolError("kg.ingest", err)
}
return jsonToolResult(map[string]any{"entity_ids": ids})
}
}
func handleKGEntityDelete(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.entity_delete", err)
}
entityID, err := req.RequireString("entity_id")
if err != nil {
return toolError("kg.entity_delete", err)
}
userID := req.GetString("user_id", "")
if err := kg.DeleteEntity(ctx, agentID, userID, entityID); err != nil {
return toolError("kg.entity_delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleKGRelationDelete(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.relation_delete", err)
}
relationID, err := req.RequireString("relation_id")
if err != nil {
return toolError("kg.relation_delete", err)
}
userID := req.GetString("user_id", "")
if err := kg.DeleteRelation(ctx, agentID, userID, relationID); err != nil {
return toolError("kg.relation_delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleKGPrune(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.prune", err)
}
userID := req.GetString("user_id", "")
var minConfidence float64
if v, ok := req.GetArguments()["min_confidence"]; ok {
if f, ok := v.(float64); ok {
minConfidence = f
}
} else {
return mcpgo.NewToolResultError("kg.prune: min_confidence is required"), nil
}
n, err := kg.PruneByConfidence(ctx, agentID, userID, minConfidence)
if err != nil {
return toolError("kg.prune", err)
}
return jsonToolResult(map[string]int{"deleted": n})
}
}
func handleKGDedupScan(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.dedup_scan", err)
}
userID := req.GetString("user_id", "")
threshold := 0.90
if v, ok := req.GetArguments()["threshold"]; ok {
if f, ok := v.(float64); ok {
threshold = f
}
}
limit := intArg(req, "limit", 100)
n, err := kg.ScanDuplicates(ctx, agentID, userID, threshold, limit)
if err != nil {
return toolError("kg.dedup_scan", err)
}
return jsonToolResult(map[string]int{"flagged": n})
}
}
func handleKGDedupCandidates(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.dedup_candidates", err)
}
userID := req.GetString("user_id", "")
limit := intArg(req, "limit", 50)
candidates, err := kg.ListDedupCandidates(ctx, agentID, userID, limit)
if err != nil {
return toolError("kg.dedup_candidates", err)
}
return jsonToolResult(candidates)
}
}
func handleKGMergeEntities(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.merge_entities", err)
}
targetID, err := req.RequireString("target_id")
if err != nil {
return toolError("kg.merge_entities", err)
}
sourceID, err := req.RequireString("source_id")
if err != nil {
return toolError("kg.merge_entities", err)
}
userID := req.GetString("user_id", "")
if err := kg.MergeEntities(ctx, agentID, userID, targetID, sourceID); err != nil {
return toolError("kg.merge_entities", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleKGDismissCandidate(kg store.KnowledgeGraphStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("kg.dismiss_candidate", err)
}
candidateID, err := req.RequireString("candidate_id")
if err != nil {
return toolError("kg.dismiss_candidate", err)
}
if err := kg.DismissCandidate(ctx, agentID, candidateID); err != nil {
return toolError("kg.dismiss_candidate", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+157
View File
@@ -0,0 +1,157 @@
package mcp
import (
"context"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// registerMemoryCRUDTools registers the goclaw_memory_* MCP tools backed by
// store.MemoryStore — closes a CLI-vs-MCP coverage gap (the `goclaw memory
// get/list/search/store/delete` commands had no MCP equivalent).
func registerMemoryCRUDTools(srv *mcpserver.MCPServer, memory store.MemoryStore) {
srv.AddTool(mcpgo.NewTool("goclaw_memory_list",
mcpgo.WithDescription("List memory documents for an agent/user scope."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global documents.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleMemoryList(memory))
srv.AddTool(mcpgo.NewTool("goclaw_memory_get",
mcpgo.WithDescription("Get a single memory document's content."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global documents.")),
mcpgo.WithString("path", mcpgo.Required(), mcpgo.Description("Document path.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleMemoryGet(memory))
srv.AddTool(mcpgo.NewTool("goclaw_memory_search",
mcpgo.WithDescription("Search memory documents (hybrid vector+text search) for an agent/user scope."),
mcpgo.WithString("query", mcpgo.Required(), mcpgo.Description("Search query.")),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global documents.")),
mcpgo.WithNumber("max_results", mcpgo.Description("Maximum results to return; server default if omitted.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleMemorySearch(memory))
srv.AddTool(mcpgo.NewTool("goclaw_memory_store",
mcpgo.WithDescription("Create or overwrite a memory document's content, then re-index it."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global documents.")),
mcpgo.WithString("path", mcpgo.Required(), mcpgo.Description("Document path.")),
mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("Document content.")),
), handleMemoryStore(memory))
srv.AddTool(mcpgo.NewTool("goclaw_memory_delete",
mcpgo.WithDescription("Delete a memory document."),
mcpgo.WithString("agent_id", mcpgo.Required(), mcpgo.Description("Agent ID scope.")),
mcpgo.WithString("user_id", mcpgo.Description("User ID scope; empty for agent-global documents.")),
mcpgo.WithString("path", mcpgo.Required(), mcpgo.Description("Document path.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleMemoryDelete(memory))
}
func handleMemoryList(memory store.MemoryStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("memory.list", err)
}
userID := req.GetString("user_id", "")
docs, err := memory.ListDocuments(ctx, agentID, userID)
if err != nil {
return toolError("memory.list", err)
}
return jsonToolResult(docs)
}
}
func handleMemoryGet(memory store.MemoryStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("memory.get", err)
}
path, err := req.RequireString("path")
if err != nil {
return toolError("memory.get", err)
}
userID := req.GetString("user_id", "")
content, err := memory.GetDocument(ctx, agentID, userID, path)
if err != nil {
return toolError("memory.get", err)
}
return jsonToolResult(map[string]string{"path": path, "content": content})
}
}
func handleMemorySearch(memory store.MemoryStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
query, err := req.RequireString("query")
if err != nil {
return toolError("memory.search", err)
}
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("memory.search", err)
}
userID := req.GetString("user_id", "")
opts := store.MemorySearchOptions{}
if v, ok := req.GetArguments()["max_results"]; ok {
if f, ok := v.(float64); ok {
opts.MaxResults = int(f)
}
}
results, err := memory.Search(ctx, query, agentID, userID, opts)
if err != nil {
return toolError("memory.search", err)
}
return jsonToolResult(results)
}
}
func handleMemoryStore(memory store.MemoryStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("memory.store", err)
}
path, err := req.RequireString("path")
if err != nil {
return toolError("memory.store", err)
}
content, err := req.RequireString("content")
if err != nil {
return toolError("memory.store", err)
}
userID := req.GetString("user_id", "")
if err := memory.PutDocument(ctx, agentID, userID, path, content); err != nil {
return toolError("memory.store", err)
}
if err := memory.IndexDocument(ctx, agentID, userID, path); err != nil {
return toolError("memory.store", err)
}
return jsonToolResult(map[string]string{"ok": "true", "path": path})
}
}
func handleMemoryDelete(memory store.MemoryStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
agentID, err := req.RequireString("agent_id")
if err != nil {
return toolError("memory.delete", err)
}
path, err := req.RequireString("path")
if err != nil {
return toolError("memory.delete", err)
}
userID := req.GetString("user_id", "")
if err := memory.DeleteDocument(ctx, agentID, userID, path); err != nil {
return toolError("memory.delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+81
View File
@@ -0,0 +1,81 @@
package mcp
import (
"context"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// registerPendingMessagesCRUDTools registers the goclaw_pending_messages_*
// MCP tools backed by store.PendingMessageStore — closes a CLI-vs-MCP
// coverage gap (`goclaw channels pending`/`pending-messages list/send`).
// "send" isn't included: pending messages are queued group-chat context
// awaiting a mention, not an outbound send path (see goclaw_send for that).
func registerPendingMessagesCRUDTools(srv *mcpserver.MCPServer, pending store.PendingMessageStore) {
srv.AddTool(mcpgo.NewTool("goclaw_pending_messages_groups",
mcpgo.WithDescription("List all pending-message groups (channel+historyKey) with counts."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handlePendingMessagesGroups(pending))
srv.AddTool(mcpgo.NewTool("goclaw_pending_messages_list",
mcpgo.WithDescription("List queued pending messages for one channel+historyKey group."),
mcpgo.WithString("channel_name", mcpgo.Required(), mcpgo.Description("Channel name.")),
mcpgo.WithString("history_key", mcpgo.Required(), mcpgo.Description("History key (thread/group identifier).")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handlePendingMessagesList(pending))
srv.AddTool(mcpgo.NewTool("goclaw_pending_messages_delete",
mcpgo.WithDescription("Delete all pending messages for one channel+historyKey group."),
mcpgo.WithString("channel_name", mcpgo.Required(), mcpgo.Description("Channel name.")),
mcpgo.WithString("history_key", mcpgo.Required(), mcpgo.Description("History key (thread/group identifier).")),
mcpgo.WithDestructiveHintAnnotation(true),
), handlePendingMessagesDelete(pending))
}
func handlePendingMessagesGroups(pending store.PendingMessageStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
groups, err := pending.ListGroups(ctx)
if err != nil {
return toolError("pending_messages.groups", err)
}
return jsonToolResult(groups)
}
}
func handlePendingMessagesList(pending store.PendingMessageStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
channelName, err := req.RequireString("channel_name")
if err != nil {
return toolError("pending_messages.list", err)
}
historyKey, err := req.RequireString("history_key")
if err != nil {
return toolError("pending_messages.list", err)
}
msgs, err := pending.ListByKey(ctx, channelName, historyKey)
if err != nil {
return toolError("pending_messages.list", err)
}
return jsonToolResult(msgs)
}
}
func handlePendingMessagesDelete(pending store.PendingMessageStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
channelName, err := req.RequireString("channel_name")
if err != nil {
return toolError("pending_messages.delete", err)
}
historyKey, err := req.RequireString("history_key")
if err != nil {
return toolError("pending_messages.delete", err)
}
if err := pending.DeleteByKey(ctx, channelName, historyKey); err != nil {
return toolError("pending_messages.delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+195
View File
@@ -0,0 +1,195 @@
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"
)
// registerProvidersCRUDTools registers the goclaw_providers_* MCP tools
// backed by store.ProviderStore — closes a CLI-vs-MCP coverage gap (the
// `goclaw providers create/list/models/verify-embedding` commands had no
// MCP equivalent for basic CRUD). deps.Providers was already threaded
// through CRUDDeps for heartbeat.set's provider-name resolution; this
// reuses the same store reference.
func registerProvidersCRUDTools(srv *mcpserver.MCPServer, providers store.ProviderStore) {
srv.AddTool(mcpgo.NewTool("goclaw_providers_list",
mcpgo.WithDescription("List all LLM providers (API keys masked)."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleProvidersList(providers))
srv.AddTool(mcpgo.NewTool("goclaw_providers_get",
mcpgo.WithDescription("Get a single LLM provider by UUID or name (API key masked)."),
mcpgo.WithString("id", mcpgo.Description("Provider UUID.")),
mcpgo.WithString("name", mcpgo.Description("Provider name, used when id is not known.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleProvidersGet(providers))
srv.AddTool(mcpgo.NewTool("goclaw_providers_create",
mcpgo.WithDescription("Register a new LLM provider. The API key is encrypted at rest and never echoed back."),
mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Provider name (unique per tenant).")),
mcpgo.WithString("provider_type", mcpgo.Required(), mcpgo.Description("Provider type (e.g. \"anthropic\", \"openai\", \"dashscope\").")),
mcpgo.WithString("display_name", mcpgo.Description("Human-readable display name; defaults to name.")),
mcpgo.WithString("api_base", mcpgo.Description("API base URL override.")),
mcpgo.WithString("api_key", mcpgo.Description("API key; stored encrypted.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state; defaults to true.")),
), handleProvidersCreate(providers))
srv.AddTool(mcpgo.NewTool("goclaw_providers_update",
mcpgo.WithDescription("Apply a partial update to an existing LLM provider."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Provider UUID.")),
mcpgo.WithString("display_name", mcpgo.Description("New display name.")),
mcpgo.WithString("api_base", mcpgo.Description("New API base URL.")),
mcpgo.WithString("api_key", mcpgo.Description("New API key; stored encrypted.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("New enabled state.")),
), handleProvidersUpdate(providers))
srv.AddTool(mcpgo.NewTool("goclaw_providers_delete",
mcpgo.WithDescription("Delete an LLM provider by UUID."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Provider UUID.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleProvidersDelete(providers))
}
// maskProviderAPIKey mirrors internal/http/providers.go's unexported
// maskAPIKey — duplicated because this MCP surface does not depend on
// internal/http (internal/http already imports internal/mcp, so the
// reverse import would cycle). Replaces a non-empty key with "***" so raw
// secrets never leave this process via an MCP tool response.
func maskProviderAPIKey(p *store.LLMProviderData) {
if p.APIKey != "" {
p.APIKey = "***"
}
}
func handleProvidersList(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
list, err := providers.ListProviders(ctx)
if err != nil {
return toolError("providers.list", err)
}
for i := range list {
maskProviderAPIKey(&list[i])
}
return jsonToolResult(list)
}
}
func handleProvidersGet(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr := req.GetString("id", "")
name := req.GetString("name", "")
switch {
case idStr != "":
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("providers.get", fmt.Errorf("invalid id: %w", err))
}
p, err := providers.GetProvider(ctx, id)
if err != nil {
return toolError("providers.get", err)
}
maskProviderAPIKey(p)
return jsonToolResult(p)
case name != "":
p, err := providers.GetProviderByName(ctx, name)
if err != nil {
return toolError("providers.get", err)
}
maskProviderAPIKey(p)
return jsonToolResult(p)
default:
return mcpgo.NewToolResultError("providers.get: one of id or name is required"), nil
}
}
}
func handleProvidersCreate(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return toolError("providers.create", err)
}
providerType, err := req.RequireString("provider_type")
if err != nil {
return toolError("providers.create", err)
}
p := &store.LLMProviderData{
Name: name,
DisplayName: req.GetString("display_name", name),
ProviderType: providerType,
APIBase: req.GetString("api_base", ""),
APIKey: req.GetString("api_key", ""),
Enabled: req.GetBool("enabled", true),
}
if err := providers.CreateProvider(ctx, p); err != nil {
return toolError("providers.create", err)
}
maskProviderAPIKey(p)
return jsonToolResult(p)
}
}
func handleProvidersUpdate(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("providers.update", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("providers.update", fmt.Errorf("invalid id: %w", err))
}
updates := map[string]any{}
args := req.GetArguments()
if v, ok := args["display_name"]; ok {
updates["display_name"] = v
}
if v, ok := args["api_base"]; ok {
updates["api_base"] = v
}
if v, ok := args["api_key"]; ok {
updates["api_key"] = v
}
if v, ok := args["enabled"]; ok {
updates["enabled"] = v
}
if len(updates) == 0 {
return mcpgo.NewToolResultError("providers.update: no fields to update"), nil
}
if err := providers.UpdateProvider(ctx, id, updates); err != nil {
return toolError("providers.update", err)
}
p, err := providers.GetProvider(ctx, id)
if err != nil {
return toolError("providers.update", err)
}
maskProviderAPIKey(p)
return jsonToolResult(p)
}
}
func handleProvidersDelete(providers store.ProviderStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("providers.delete", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("providers.delete", fmt.Errorf("invalid id: %w", err))
}
if err := providers.DeleteProvider(ctx, id); err != nil {
return toolError("providers.delete", err)
}
return jsonToolResult(map[string]bool{"deleted": true})
}
}
+169
View File
@@ -0,0 +1,169 @@
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"
)
// registerSecureCLICRUDTools registers the goclaw_secure_cli_binaries_* MCP
// tools backed by store.SecureCLIStore — this is the closest real
// server-side resource to the CLI's local `goclaw credentials` command,
// which is otherwise out of scope for this MCP surface (it manages the
// `goclaw` CLI's own auth profile in ~/.goclaw/config.yaml + OS keychain,
// a client-local concept with no server API to wrap). SecureCLIStore
// instead manages which exec-sandboxed binaries (gh, git, etc.) are
// credential-gated and how — the actual secret values (encrypted_env) are
// never exposed here (SecureCLIBinary.EncryptedEnv/UserEnv are json:"-").
// Per-user/per-agent credential value CRUD (SetUserCredentials et al.,
// internal/http/secure_cli_user_credentials.go /
// secure_cli_agent_credentials.go) needs the same encryption handling the
// HTTP layer owns and is not exposed here.
func registerSecureCLICRUDTools(srv *mcpserver.MCPServer, secureCLI store.SecureCLIStore) {
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_list",
mcpgo.WithDescription("List registered secure-CLI binary configs (which sandboxed exec binaries are credential-gated)."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSecureCLIBinariesList(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_get",
mcpgo.WithDescription("Get a single secure-CLI binary config by UUID."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSecureCLIBinariesGet(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_create",
mcpgo.WithDescription("Register a new secure-CLI binary config."),
mcpgo.WithString("binary_name", mcpgo.Required(), mcpgo.Description("Binary name (e.g. \"gh\", \"git\").")),
mcpgo.WithString("description", mcpgo.Description("Description shown to agents.")),
mcpgo.WithBoolean("is_global", mcpgo.Description("Whether all agents can use this binary without a grant; defaults to false.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("Enabled state; defaults to true.")),
mcpgo.WithNumber("timeout_seconds", mcpgo.Description("Exec timeout in seconds.")),
), handleSecureCLIBinariesCreate(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_update",
mcpgo.WithDescription("Apply a partial update to a secure-CLI binary config."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithString("description", mcpgo.Description("New description.")),
mcpgo.WithBoolean("is_global", mcpgo.Description("New is_global value.")),
mcpgo.WithBoolean("enabled", mcpgo.Description("New enabled value.")),
mcpgo.WithNumber("timeout_seconds", mcpgo.Description("New exec timeout in seconds.")),
), handleSecureCLIBinariesUpdate(secureCLI))
srv.AddTool(mcpgo.NewTool("goclaw_secure_cli_binaries_delete",
mcpgo.WithDescription("Delete a secure-CLI binary config."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Binary config UUID.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleSecureCLIBinariesDelete(secureCLI))
}
func handleSecureCLIBinariesList(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
list, err := secureCLI.List(ctx)
if err != nil {
return toolError("secure_cli_binaries.list", err)
}
return jsonToolResult(list)
}
}
func handleSecureCLIBinariesGet(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.get", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.get", fmt.Errorf("invalid id: %w", err))
}
b, err := secureCLI.Get(ctx, id)
if err != nil {
return toolError("secure_cli_binaries.get", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesCreate(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
binaryName, err := req.RequireString("binary_name")
if err != nil {
return toolError("secure_cli_binaries.create", err)
}
b := &store.SecureCLIBinary{
BinaryName: binaryName,
Description: req.GetString("description", ""),
IsGlobal: req.GetBool("is_global", false),
Enabled: req.GetBool("enabled", true),
TimeoutSeconds: intArg(req, "timeout_seconds", 0),
CreatedBy: "mcp",
}
if err := secureCLI.Create(ctx, b); err != nil {
return toolError("secure_cli_binaries.create", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesUpdate(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.update", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.update", fmt.Errorf("invalid id: %w", err))
}
updates := map[string]any{}
args := req.GetArguments()
if v, ok := args["description"]; ok {
updates["description"] = v
}
if v, ok := args["is_global"]; ok {
updates["is_global"] = v
}
if v, ok := args["enabled"]; ok {
updates["enabled"] = v
}
if v, ok := args["timeout_seconds"]; ok {
updates["timeout_seconds"] = v
}
if len(updates) == 0 {
return mcpgo.NewToolResultError("secure_cli_binaries.update: no fields to update"), nil
}
if err := secureCLI.Update(ctx, id, updates); err != nil {
return toolError("secure_cli_binaries.update", err)
}
b, err := secureCLI.Get(ctx, id)
if err != nil {
return toolError("secure_cli_binaries.update", err)
}
return jsonToolResult(b)
}
}
func handleSecureCLIBinariesDelete(secureCLI store.SecureCLIStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("secure_cli_binaries.delete", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("secure_cli_binaries.delete", fmt.Errorf("invalid id: %w", err))
}
if err := secureCLI.Delete(ctx, id); err != nil {
return toolError("secure_cli_binaries.delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+69
View File
@@ -85,6 +85,17 @@ type CRUDDeps struct {
// store.MasterTenantID (same fail-safe default resolveMCPTenantID uses
// when the header is absent/unresolvable).
Tenants store.TenantStore
// Phase 4: read-heavy/admin surfaces (memory, knowledge graph, tracing,
// tenants, providers) closing the CLI-vs-MCP coverage gap.
Memory store.MemoryStore
KnowledgeGraph store.KnowledgeGraphStore
Tracing store.TracingStore
Contacts store.ContactStore
PendingMessages store.PendingMessageStore
Activity store.ActivityStore
SystemConfigs store.SystemConfigStore
SecureCLI store.SecureCLIStore
}
// NewCRUDServer builds a StreamableHTTPServer exposing goclaw's CRUD
@@ -102,6 +113,8 @@ func NewCRUDServer(deps CRUDDeps, version string) *mcpserver.StreamableHTTPServe
if deps.Agents != nil {
registerAgentCRUDTools(srv, deps.Agents)
registered += 5
registerAgentSkillPinCRUDTools(srv, deps.Agents)
registered += 2
}
if deps.Agents != nil && deps.AgentRuntime != nil {
registerAgentRuntimeCRUDTools(srv, deps.Agents, deps.AgentRuntime)
@@ -117,9 +130,13 @@ func NewCRUDServer(deps CRUDDeps, version string) *mcpserver.StreamableHTTPServe
if manage, ok := deps.Skills.(store.SkillManageStore); ok {
registerSkillUpdateCRUDTool(srv, deps.Skills, manage)
registered++
registerSkillGrantCRUDTools(srv, deps.Skills, manage)
registered += 2
if deps.Config != nil {
registerSkillWriteFileCRUDTool(srv, deps.Skills, manage, deps.Config)
registered++
registerSkillCreateCRUDTool(srv, manage, deps.Config)
registered++
}
}
}
@@ -218,6 +235,58 @@ func NewCRUDServer(deps CRUDDeps, version string) *mcpserver.StreamableHTTPServe
registered += 2
}
// Phase 4: read-heavy/admin surfaces closing CLI-vs-MCP coverage gaps.
if deps.Memory != nil {
registerMemoryCRUDTools(srv, deps.Memory)
registered += 5
}
if deps.KnowledgeGraph != nil {
registerKnowledgeGraphCRUDTools(srv, deps.KnowledgeGraph)
registered += 14
}
if deps.Tenants != nil {
registerTenantsCRUDTools(srv, deps.Tenants)
registered += 6
}
if deps.Providers != nil {
registerProvidersCRUDTools(srv, deps.Providers)
registered += 5
}
if deps.Tracing != nil {
registerTracesCRUDTools(srv, deps.Tracing)
registered += 2
}
if deps.Contacts != nil {
registerContactsCRUDTools(srv, deps.Contacts)
registered += 4
}
if deps.PendingMessages != nil {
registerPendingMessagesCRUDTools(srv, deps.PendingMessages)
registered += 3
}
if deps.Activity != nil {
registerActivityCRUDTools(srv, deps.Activity)
registered++
}
if deps.SystemConfigs != nil {
registerSystemConfigCRUDTools(srv, deps.SystemConfigs)
registered += 4
}
if deps.Config != nil {
registerStorageCRUDTools(srv, deps.Config)
registered += 4
}
if deps.Agents != nil {
registerAgentExportCRUDTools(srv, deps.Agents)
registered += 2
}
if deps.SecureCLI != nil {
registerSecureCLICRUDTools(srv, deps.SecureCLI)
registered += 5
}
registerHealthCRUDTool(srv, deps.DB, version)
registered++
slog.Info("mcp.crud: tools registered", "count", registered)
tenants := deps.Tenants
+171 -34
View File
@@ -71,23 +71,9 @@ func handleSkillsUpdate(skills store.SkillStore, manage store.SkillManageStore)
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
skillID, err := resolveSkillID(ctx, skills, idStr, name)
if err != nil {
return toolError("skills.update", err)
}
args := req.GetArguments()
@@ -104,6 +90,53 @@ func handleSkillsUpdate(skills store.SkillStore, manage store.SkillManageStore)
}
}
// registerSkillCreateCRUDTool registers goclaw_skills_create, letting MCP
// callers create a new managed skill from SKILL.md content — the single-file
// equivalent of the web UI's ZIP-based skill upload
// (SkillsHandler.handleUpload in internal/http/skills_upload.go), via
// skills.CreateFromContent. There is no per-caller identity on this MCP
// surface (see crud_server.go doc comment), so owner_id is an explicit
// param — same pattern as registerAgentCRUDTools' goclaw_agents_create,
// which defaults owner_id to "system" when omitted.
func registerSkillCreateCRUDTool(srv *mcpserver.MCPServer, manage store.SkillManageStore, cfg *config.Config) {
srv.AddTool(mcpgo.NewTool("goclaw_skills_create",
mcpgo.WithDescription("Create a new managed skill from a SKILL.md content string (name/slug/description are parsed from its YAML frontmatter)."),
mcpgo.WithString("content", mcpgo.Required(), mcpgo.Description("Full SKILL.md content, including YAML frontmatter (name, slug, description).")),
mcpgo.WithString("owner_id", mcpgo.Description("Owner user ID; defaults to \"system\".")),
), handleSkillsCreate(manage, cfg))
}
func handleSkillsCreate(manage store.SkillManageStore, cfg *config.Config) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
content, err := req.RequireString("content")
if err != nil {
return toolError("skills.create", err)
}
ownerID := req.GetString("owner_id", "system")
tenantID := store.TenantIDFromContext(ctx)
tenantSlug := store.TenantSlugFromContext(ctx)
tenantSkillsDir := config.TenantSkillsStoreDir(cfg.DataDir, tenantID, tenantSlug)
id, slug, err := skills.CreateFromContent(ctx, manage, tenantSkillsDir, content, ownerID)
if err != nil {
switch {
case errors.Is(err, skills.ErrSkillNameRequired):
return mcpgo.NewToolResultError("skills.create: name is required in SKILL.md frontmatter"), nil
case errors.Is(err, skills.ErrSkillSlugInvalid):
return mcpgo.NewToolResultError("skills.create: invalid slug"), nil
case errors.Is(err, skills.ErrSkillSlugConflict):
return mcpgo.NewToolResultError("skills.create: slug conflicts with a system skill"), nil
case errors.Is(err, skills.ErrSkillGuardRejected):
return mcpgo.NewToolResultError("skills.create: " + err.Error()), nil
default:
return toolError("skills.create", err)
}
}
return jsonToolResult(map[string]any{"id": id.String(), "slug": slug})
}
}
// 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
@@ -138,23 +171,9 @@ func handleSkillsWriteFile(skillStore store.SkillStore, manage store.SkillManage
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
skillID, err := resolveSkillID(ctx, skillStore, idStr, name)
if err != nil {
return toolError("skills.write_file", err)
}
tenantID := store.TenantIDFromContext(ctx)
@@ -177,3 +196,121 @@ func handleSkillsWriteFile(skillStore store.SkillStore, manage store.SkillManage
return jsonToolResult(map[string]any{"ok": "true", "path": path, "version": version})
}
}
// registerSkillGrantCRUDTools registers goclaw_skills_grant/goclaw_skills_revoke,
// letting MCP callers grant/revoke an agent's access to a skill — mirrors the
// goclaw CLI's `skills grant`/`skills revoke` (internal/http/skills_grants.go
// handleGrantAgent/handleRevokeAgent) via store.SkillManageStore.GrantToAgent/
// RevokeFromAgent. Note: granting access is distinct from pinning a skill
// into an agent's always-loaded context — see registerAgentSkillPinCRUDTools.
func registerSkillGrantCRUDTools(srv *mcpserver.MCPServer, skillStore store.SkillStore, manage store.SkillManageStore) {
srv.AddTool(mcpgo.NewTool("goclaw_skills_grant",
mcpgo.WithDescription("Grant an agent access to a 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("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID to grant access to.")),
mcpgo.WithNumber("version", mcpgo.Description("Skill version to pin the grant to; defaults to the skill's current version.")),
mcpgo.WithBoolean("can_manage", mcpgo.Description("Whether the granted agent can also edit/manage this skill (default false).")),
), handleSkillsGrant(skillStore, manage))
srv.AddTool(mcpgo.NewTool("goclaw_skills_revoke",
mcpgo.WithDescription("Revoke an agent's access to a 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("agent_id", mcpgo.Required(), mcpgo.Description("Agent UUID to revoke access from.")),
), handleSkillsRevoke(skillStore, manage))
}
// resolveSkillID resolves a skill UUID from either an explicit id or a
// name lookup — shared by every goclaw_skills_* tool that accepts both.
func resolveSkillID(ctx context.Context, skillStore store.SkillStore, idStr, name string) (uuid.UUID, error) {
if idStr != "" {
id, err := uuid.Parse(idStr)
if err != nil {
return uuid.Nil, fmt.Errorf("invalid id: %w", err)
}
return id, nil
}
info, ok := skillStore.GetSkill(ctx, name)
if !ok {
return uuid.Nil, fmt.Errorf("skill not found: %s", name)
}
id, err := uuid.Parse(info.ID)
if err != nil {
return uuid.Nil, fmt.Errorf("cannot resolve skill id: %w", err)
}
return id, nil
}
func handleSkillsGrant(skillStore 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.grant: one of name or id is required"), nil
}
agentIDStr, err := req.RequireString("agent_id")
if err != nil {
return toolError("skills.grant", err)
}
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
return toolError("skills.grant", fmt.Errorf("invalid agent_id: %w", err))
}
skillID, err := resolveSkillID(ctx, skillStore, idStr, name)
if err != nil {
return toolError("skills.grant", err)
}
version := 0
if v, ok := req.GetArguments()["version"]; ok {
if f, ok := v.(float64); ok {
version = int(f)
}
}
if version == 0 {
info, ok := manage.GetSkillByID(ctx, skillID)
if !ok {
return mcpgo.NewToolResultError("skills.grant: skill not found"), nil
}
version = info.Version
}
canManage := req.GetBool("can_manage", false)
if err := manage.GrantToAgent(ctx, skillID, agentID, version, "mcp", canManage); err != nil {
return toolError("skills.grant", err)
}
skillStore.BumpVersion()
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleSkillsRevoke(skillStore 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.revoke: one of name or id is required"), nil
}
agentIDStr, err := req.RequireString("agent_id")
if err != nil {
return toolError("skills.revoke", err)
}
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
return toolError("skills.revoke", fmt.Errorf("invalid agent_id: %w", err))
}
skillID, err := resolveSkillID(ctx, skillStore, idStr, name)
if err != nil {
return toolError("skills.revoke", err)
}
if err := manage.RevokeFromAgent(ctx, skillID, agentID); err != nil {
return toolError("skills.revoke", err)
}
skillStore.BumpVersion()
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+400
View File
@@ -0,0 +1,400 @@
package mcp
import (
"context"
"os"
"path/filepath"
"strings"
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"
)
// registerStorageCRUDTools registers the goclaw_storage_* MCP tools, closing
// the `goclaw storage` CLI-vs-MCP coverage gap. Path validation (traversal,
// symlink escape, tenant-isolation hiding, protected top-level dirs) mirrors
// internal/http/storage.go's handleList/handleSize/handleDelete/handleMove
// and their isHiddenPath/validateExistingStoragePath/validateStorageParent
// helpers — duplicated because this MCP surface does not depend on
// internal/http (which already imports internal/mcp; the reverse import
// would cycle). delete/move refuse to touch protectedDirs (skills,
// skills-store, media, tenants) same as the HTTP handler.
func registerStorageCRUDTools(srv *mcpserver.MCPServer, cfg *config.Config) {
srv.AddTool(mcpgo.NewTool("goclaw_storage_list",
mcpgo.WithDescription("List files and directories under the tenant's data directory."),
mcpgo.WithString("path", mcpgo.Description("Subpath to scope the listing to; empty lists the data dir root.")),
mcpgo.WithNumber("depth", mcpgo.Description("Max depth to walk (1-20, default 3).")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleStorageList(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_storage_size",
mcpgo.WithDescription("Compute total size and file count under the tenant's data directory (or a subpath)."),
mcpgo.WithString("path", mcpgo.Description("Subpath to scope the calculation to; empty sizes the whole data dir.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleStorageSize(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_storage_delete",
mcpgo.WithDescription("Delete a file or directory under the tenant's data directory. Refuses protected top-level dirs (skills, skills-store, media, tenants)."),
mcpgo.WithString("path", mcpgo.Required(), mcpgo.Description("Path to delete, relative to the data dir root.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleStorageDelete(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_storage_move",
mcpgo.WithDescription("Move/rename a file or directory within the tenant's data directory. Refuses protected top-level dirs and existing destinations."),
mcpgo.WithString("from", mcpgo.Required(), mcpgo.Description("Source path, relative to the data dir root.")),
mcpgo.WithString("to", mcpgo.Required(), mcpgo.Description("Destination path, relative to the data dir root.")),
), handleStorageMove(cfg))
}
// storageProtectedDirs mirrors internal/http/storage.go's protectedDirs.
var storageProtectedDirs = []string{"skills", "skills-store", "media", "tenants"}
func storageTopLevelPath(rel string) string {
if before, _, ok := strings.Cut(rel, "/"); ok {
return before
}
return rel
}
func storageIsProtectedPath(rel string) bool {
top := storageTopLevelPath(rel)
for _, d := range storageProtectedDirs {
if strings.EqualFold(top, d) {
return true
}
}
return false
}
// storageIsHiddenPath mirrors isHiddenPath: master tenant must not see the
// cross-tenant isolation root ("tenants/") in its own listing.
func storageIsHiddenPath(ctx context.Context, rel string) bool {
if rel == "" {
return false
}
if store.TenantIDFromContext(ctx) != store.MasterTenantID {
return false
}
return strings.EqualFold(storageTopLevelPath(rel), "tenants")
}
func storageTenantBaseDir(ctx context.Context, cfg *config.Config) string {
tid := store.TenantIDFromContext(ctx)
slug := store.TenantSlugFromContext(ctx)
return config.TenantDataDir(cfg.DataDir, tid, slug)
}
// storageEvalSymlinkOrClean mirrors evalSymlinkOrClean.
func storageEvalSymlinkOrClean(path string) string {
if realPath, err := filepath.EvalSymlinks(path); err == nil {
return filepath.Clean(realPath)
}
return filepath.Clean(path)
}
// storagePathWithinDir mirrors pathWithinDir.
func storagePathWithinDir(path, dir string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// storageIsHiddenRealPath mirrors isHiddenRealPath.
func storageIsHiddenRealPath(ctx context.Context, base, realPath string) bool {
if store.TenantIDFromContext(ctx) != store.MasterTenantID {
return false
}
realTenantRoot, err := filepath.EvalSymlinks(filepath.Join(base, "tenants"))
if err != nil {
return false
}
return storagePathWithinDir(filepath.Clean(realPath), filepath.Clean(realTenantRoot))
}
// storageValidateExistingPath mirrors validateExistingStoragePath: resolves
// symlinks and confirms the real path stays within base and isn't the
// hidden cross-tenant isolation root.
func storageValidateExistingPath(ctx context.Context, base, absPath string) bool {
realBase := storageEvalSymlinkOrClean(base)
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return false
}
realPath = filepath.Clean(realPath)
if !storagePathWithinDir(realPath, realBase) {
return false
}
return !storageIsHiddenRealPath(ctx, base, realPath)
}
// storageValidateParent mirrors validateStorageParent: walks up from parent
// until it finds a real (symlink-resolved) ancestor, confirming every
// resolvable ancestor stays within base and isn't the hidden isolation root.
func storageValidateParent(ctx context.Context, base, parent string) bool {
realBase := storageEvalSymlinkOrClean(base)
current := filepath.Clean(parent)
for {
if realParent, err := filepath.EvalSymlinks(current); err == nil {
realParent = filepath.Clean(realParent)
if !storagePathWithinDir(realParent, realBase) {
return false
}
return !storageIsHiddenRealPath(ctx, base, realParent)
}
next := filepath.Dir(current)
if next == current {
return false
}
current = next
}
}
func handleStorageList(cfg *config.Config) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
subPath := req.GetString("path", "")
if strings.Contains(subPath, "..") {
return mcpgo.NewToolResultError("storage.list: invalid path"), nil
}
base := storageTenantBaseDir(ctx, cfg)
rootDir := base
if subPath != "" {
if storageIsHiddenPath(ctx, subPath) {
return mcpgo.NewToolResultError("storage.list: path not found: " + subPath), nil
}
rootDir = filepath.Join(base, filepath.Clean(subPath))
if !strings.HasPrefix(rootDir, base) {
return mcpgo.NewToolResultError("storage.list: invalid path"), nil
}
}
maxDepth := intArg(req, "depth", 3)
if maxDepth < 1 || maxDepth > 20 {
maxDepth = 3
}
type fileEntry struct {
Path string `json:"path"`
Name string `json:"name"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
HasChildren bool `json:"hasChildren,omitempty"`
Protected bool `json:"protected"`
}
var entries []fileEntry
filepath.WalkDir(rootDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if path == rootDir {
return nil
}
rel, _ := filepath.Rel(base, path)
if storageIsHiddenPath(ctx, rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if skills.IsSystemArtifact(rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
relToRoot, _ := filepath.Rel(rootDir, path)
depth := strings.Count(relToRoot, string(filepath.Separator)) + 1
if d.IsDir() && depth > maxDepth {
e := fileEntry{Path: rel, Name: d.Name(), IsDir: true, Protected: storageIsProtectedPath(rel)}
if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 {
e.HasChildren = true
}
entries = append(entries, e)
return filepath.SkipDir
}
entry := fileEntry{Path: rel, Name: d.Name(), IsDir: d.IsDir()}
if !d.IsDir() {
if info, err := d.Info(); err == nil {
entry.Size = info.Size()
}
}
if d.IsDir() && depth == maxDepth {
if dirEntries, err := os.ReadDir(path); err == nil && len(dirEntries) > 0 {
entry.HasChildren = true
}
}
entry.Protected = storageIsProtectedPath(rel)
entries = append(entries, entry)
return nil
})
if entries == nil {
entries = []fileEntry{}
}
return jsonToolResult(map[string]any{"files": entries, "baseDir": base})
}
}
func handleStorageSize(cfg *config.Config) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
subPath := req.GetString("path", "")
if strings.Contains(subPath, "..") {
return mcpgo.NewToolResultError("storage.size: invalid path"), nil
}
base := storageTenantBaseDir(ctx, cfg)
sizeBase := base
if subPath != "" {
if storageIsHiddenPath(ctx, subPath) {
return mcpgo.NewToolResultError("storage.size: path not found: " + subPath), nil
}
sizeBase = filepath.Join(base, filepath.Clean(subPath))
if !strings.HasPrefix(sizeBase, base) {
return mcpgo.NewToolResultError("storage.size: invalid path"), nil
}
}
var total int64
var fileCount int
filepath.WalkDir(sizeBase, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(base, path)
if storageIsHiddenPath(ctx, rel) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if skills.IsSystemArtifact(rel) {
return nil
}
if info, err := d.Info(); err == nil {
total += info.Size()
fileCount++
}
return nil
})
return jsonToolResult(map[string]any{"total": total, "files": fileCount})
}
}
func handleStorageDelete(cfg *config.Config) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
relPath, err := req.RequireString("path")
if err != nil {
return toolError("storage.delete", err)
}
if strings.Contains(relPath, "..") {
return mcpgo.NewToolResultError("storage.delete: invalid path"), nil
}
if storageIsProtectedPath(relPath) {
return mcpgo.NewToolResultError("storage.delete: cannot delete a protected directory"), nil
}
base := storageTenantBaseDir(ctx, cfg)
absPath := filepath.Join(base, filepath.Clean(relPath))
if !strings.HasPrefix(absPath, base+string(filepath.Separator)) {
return mcpgo.NewToolResultError("storage.delete: invalid path"), nil
}
info, err := os.Lstat(absPath)
if err != nil {
return mcpgo.NewToolResultError("storage.delete: not found: " + relPath), nil
}
if !storageValidateExistingPath(ctx, base, absPath) {
return mcpgo.NewToolResultError("storage.delete: not found: " + relPath), nil
}
switch {
case info.Mode()&os.ModeSymlink != 0:
err = os.Remove(absPath)
case info.IsDir():
err = os.RemoveAll(absPath)
default:
err = os.Remove(absPath)
}
if err != nil {
return toolError("storage.delete", err)
}
return jsonToolResult(map[string]string{"status": "deleted", "path": relPath})
}
}
func handleStorageMove(cfg *config.Config) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
fromRel, err := req.RequireString("from")
if err != nil {
return toolError("storage.move", err)
}
toRel, err := req.RequireString("to")
if err != nil {
return toolError("storage.move", err)
}
if strings.Contains(fromRel, "..") || strings.Contains(toRel, "..") {
return mcpgo.NewToolResultError("storage.move: invalid path"), nil
}
if storageIsProtectedPath(fromRel) || storageIsProtectedPath(toRel) {
return mcpgo.NewToolResultError("storage.move: cannot move a protected directory"), nil
}
base := storageTenantBaseDir(ctx, cfg)
srcAbs := filepath.Join(base, filepath.Clean(fromRel))
if !strings.HasPrefix(srcAbs, base+string(filepath.Separator)) {
return mcpgo.NewToolResultError("storage.move: invalid path"), nil
}
srcReal, err := filepath.EvalSymlinks(srcAbs)
if err != nil {
return mcpgo.NewToolResultError("storage.move: source not found"), nil
}
baseReal := storageEvalSymlinkOrClean(base)
srcReal = filepath.Clean(srcReal)
if !storagePathWithinDir(srcReal, baseReal) || storageIsHiddenRealPath(ctx, base, srcReal) {
return mcpgo.NewToolResultError("storage.move: invalid path"), nil
}
destAbs := filepath.Join(base, filepath.Clean(toRel))
if !strings.HasPrefix(destAbs, base+string(filepath.Separator)) {
return mcpgo.NewToolResultError("storage.move: invalid path"), nil
}
destDir := filepath.Dir(destAbs)
if !storageValidateParent(ctx, base, destDir) {
return mcpgo.NewToolResultError("storage.move: invalid destination path"), nil
}
if err := os.MkdirAll(destDir, 0750); err != nil {
return toolError("storage.move", err)
}
if !storageValidateParent(ctx, base, destDir) {
return mcpgo.NewToolResultError("storage.move: invalid destination path"), nil
}
if _, err := os.Stat(destAbs); err == nil {
return mcpgo.NewToolResultError("storage.move: a file already exists at the destination"), nil
}
if err := os.Rename(srcAbs, destAbs); err != nil {
return toolError("storage.move", err)
}
return jsonToolResult(map[string]any{"from": fromRel, "to": toRel})
}
}
+93
View File
@@ -0,0 +1,93 @@
package mcp
import (
"context"
mcpgo "github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// registerSystemConfigCRUDTools registers the goclaw_system_config_* MCP
// tools backed by store.SystemConfigStore — closes a CLI-vs-MCP coverage
// gap (`goclaw system-config list`, plus set/delete which the CLI reference
// didn't surface but the store supports).
func registerSystemConfigCRUDTools(srv *mcpserver.MCPServer, cfg store.SystemConfigStore) {
srv.AddTool(mcpgo.NewTool("goclaw_system_config_list",
mcpgo.WithDescription("List all system config key/value pairs visible to the current tenant (master merged with tenant overrides)."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSystemConfigList(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_system_config_get",
mcpgo.WithDescription("Get a single system config value by key."),
mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Config key.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleSystemConfigGet(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_system_config_set",
mcpgo.WithDescription("Set a system config value for the current tenant."),
mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Config key.")),
mcpgo.WithString("value", mcpgo.Required(), mcpgo.Description("Config value.")),
), handleSystemConfigSet(cfg))
srv.AddTool(mcpgo.NewTool("goclaw_system_config_delete",
mcpgo.WithDescription("Delete a system config value for the current tenant."),
mcpgo.WithString("key", mcpgo.Required(), mcpgo.Description("Config key.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleSystemConfigDelete(cfg))
}
func handleSystemConfigList(cfg store.SystemConfigStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
all, err := cfg.List(ctx)
if err != nil {
return toolError("system_config.list", err)
}
return jsonToolResult(all)
}
}
func handleSystemConfigGet(cfg store.SystemConfigStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
key, err := req.RequireString("key")
if err != nil {
return toolError("system_config.get", err)
}
value, err := cfg.Get(ctx, key)
if err != nil {
return toolError("system_config.get", err)
}
return jsonToolResult(map[string]string{"key": key, "value": value})
}
}
func handleSystemConfigSet(cfg store.SystemConfigStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
key, err := req.RequireString("key")
if err != nil {
return toolError("system_config.set", err)
}
value, err := req.RequireString("value")
if err != nil {
return toolError("system_config.set", err)
}
if err := cfg.Set(ctx, key, value); err != nil {
return toolError("system_config.set", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleSystemConfigDelete(cfg store.SystemConfigStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
key, err := req.RequireString("key")
if err != nil {
return toolError("system_config.delete", err)
}
if err := cfg.Delete(ctx, key); err != nil {
return toolError("system_config.delete", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+179
View File
@@ -0,0 +1,179 @@
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"
)
// registerTenantsCRUDTools registers the goclaw_tenants_* MCP tools backed
// by store.TenantStore — closes a CLI-vs-MCP coverage gap (the `goclaw
// tenants create/list/users` commands had no MCP equivalent). deps.Tenants
// was already threaded through CRUDDeps for the "X-GoClaw-Tenant-Id" header
// resolution (see crud_server.go); this reuses the same store reference.
func registerTenantsCRUDTools(srv *mcpserver.MCPServer, tenants store.TenantStore) {
srv.AddTool(mcpgo.NewTool("goclaw_tenants_list",
mcpgo.WithDescription("List all tenants."),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleTenantsList(tenants))
srv.AddTool(mcpgo.NewTool("goclaw_tenants_get",
mcpgo.WithDescription("Get a single tenant by UUID or slug."),
mcpgo.WithString("id", mcpgo.Description("Tenant UUID.")),
mcpgo.WithString("slug", mcpgo.Description("Tenant slug, used when id is not known.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleTenantsGet(tenants))
srv.AddTool(mcpgo.NewTool("goclaw_tenants_create",
mcpgo.WithDescription("Create a new tenant."),
mcpgo.WithString("name", mcpgo.Required(), mcpgo.Description("Tenant display name.")),
mcpgo.WithString("slug", mcpgo.Required(), mcpgo.Description("Tenant slug (URL/path-safe identifier).")),
), handleTenantsCreate(tenants))
srv.AddTool(mcpgo.NewTool("goclaw_tenants_users_list",
mcpgo.WithDescription("List a tenant's member users."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleTenantsUsersList(tenants))
srv.AddTool(mcpgo.NewTool("goclaw_tenants_users_add",
mcpgo.WithDescription("Add a user to a tenant with a given role."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")),
mcpgo.WithString("user_id", mcpgo.Required(), mcpgo.Description("User ID to add.")),
mcpgo.WithString("role", mcpgo.Description("Tenant role; defaults to \"member\".")),
), handleTenantsUsersAdd(tenants))
srv.AddTool(mcpgo.NewTool("goclaw_tenants_users_remove",
mcpgo.WithDescription("Remove a user from a tenant."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Tenant UUID.")),
mcpgo.WithString("user_id", mcpgo.Required(), mcpgo.Description("User ID to remove.")),
mcpgo.WithDestructiveHintAnnotation(true),
), handleTenantsUsersRemove(tenants))
}
func handleTenantsList(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
list, err := tenants.ListTenants(ctx)
if err != nil {
return toolError("tenants.list", err)
}
return jsonToolResult(list)
}
}
func handleTenantsGet(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr := req.GetString("id", "")
slug := req.GetString("slug", "")
switch {
case idStr != "":
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("tenants.get", fmt.Errorf("invalid id: %w", err))
}
t, err := tenants.GetTenant(ctx, id)
if err != nil {
return toolError("tenants.get", err)
}
return jsonToolResult(t)
case slug != "":
t, err := tenants.GetTenantBySlug(ctx, slug)
if err != nil {
return toolError("tenants.get", err)
}
return jsonToolResult(t)
default:
return mcpgo.NewToolResultError("tenants.get: one of id or slug is required"), nil
}
}
}
func handleTenantsCreate(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return toolError("tenants.create", err)
}
slug, err := req.RequireString("slug")
if err != nil {
return toolError("tenants.create", err)
}
t := &store.TenantData{
ID: store.GenNewID(),
Name: name,
Slug: slug,
Status: "active",
}
if err := tenants.CreateTenant(ctx, t); err != nil {
return toolError("tenants.create", err)
}
return jsonToolResult(t)
}
}
func handleTenantsUsersList(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("tenants.users_list", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("tenants.users_list", fmt.Errorf("invalid id: %w", err))
}
users, err := tenants.ListUsers(ctx, id)
if err != nil {
return toolError("tenants.users_list", err)
}
return jsonToolResult(users)
}
}
func handleTenantsUsersAdd(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("tenants.users_add", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("tenants.users_add", fmt.Errorf("invalid id: %w", err))
}
userID, err := req.RequireString("user_id")
if err != nil {
return toolError("tenants.users_add", err)
}
role := req.GetString("role", "member")
if err := tenants.AddUser(ctx, id, userID, role); err != nil {
return toolError("tenants.users_add", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
func handleTenantsUsersRemove(tenants store.TenantStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("tenants.users_remove", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("tenants.users_remove", fmt.Errorf("invalid id: %w", err))
}
userID, err := req.RequireString("user_id")
if err != nil {
return toolError("tenants.users_remove", err)
}
if err := tenants.RemoveUser(ctx, id, userID); err != nil {
return toolError("tenants.users_remove", err)
}
return jsonToolResult(map[string]string{"ok": "true"})
}
}
+83
View File
@@ -0,0 +1,83 @@
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"
)
// registerTracesCRUDTools registers the goclaw_traces_* MCP tools backed by
// store.TracingStore — closes a CLI-vs-MCP coverage gap (the `goclaw traces
// get/list` commands had no MCP equivalent; goclaw_run_timeline_get covers a
// different, run-oriented view). Read-only: trace/span data is written by
// the tracing pipeline itself (internal/tracing), not by operators.
func registerTracesCRUDTools(srv *mcpserver.MCPServer, tracing store.TracingStore) {
srv.AddTool(mcpgo.NewTool("goclaw_traces_list",
mcpgo.WithDescription("List LLM call traces, optionally filtered by agent/user/session/status."),
mcpgo.WithString("agent_id", mcpgo.Description("Filter by agent UUID.")),
mcpgo.WithString("user_id", mcpgo.Description("Filter by user ID.")),
mcpgo.WithString("session_key", mcpgo.Description("Filter by session key.")),
mcpgo.WithString("status", mcpgo.Description("Filter by status (e.g. \"success\", \"error\").")),
mcpgo.WithNumber("limit", mcpgo.Description("Maximum traces to return; defaults to 50.")),
mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleTracesList(tracing))
srv.AddTool(mcpgo.NewTool("goclaw_traces_get",
mcpgo.WithDescription("Get a single trace and its spans by UUID."),
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Trace UUID.")),
mcpgo.WithReadOnlyHintAnnotation(true),
), handleTracesGet(tracing))
}
func handleTracesList(tracing store.TracingStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
opts := store.TraceListOpts{
UserID: req.GetString("user_id", ""),
SessionKey: req.GetString("session_key", ""),
Status: req.GetString("status", ""),
Limit: intArg(req, "limit", 50),
Offset: intArg(req, "offset", 0),
}
if agentIDStr := req.GetString("agent_id", ""); agentIDStr != "" {
agentID, err := uuid.Parse(agentIDStr)
if err != nil {
return toolError("traces.list", fmt.Errorf("invalid agent_id: %w", err))
}
opts.AgentID = &agentID
}
traces, err := tracing.ListTraces(ctx, opts)
if err != nil {
return toolError("traces.list", err)
}
return jsonToolResult(traces)
}
}
func handleTracesGet(tracing store.TracingStore) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
idStr, err := req.RequireString("id")
if err != nil {
return toolError("traces.get", err)
}
id, err := uuid.Parse(idStr)
if err != nil {
return toolError("traces.get", fmt.Errorf("invalid id: %w", err))
}
trace, err := tracing.GetTrace(ctx, id)
if err != nil {
return toolError("traces.get", err)
}
spans, err := tracing.GetTraceSpans(ctx, id)
if err != nil {
return toolError("traces.get", err)
}
return jsonToolResult(map[string]any{"trace": trace, "spans": spans})
}
}
+84
View File
@@ -0,0 +1,84 @@
package skills
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// Errors returned by CreateFromContent.
var (
ErrSkillNameRequired = errors.New("name is required in SKILL.md frontmatter")
ErrSkillSlugInvalid = errors.New("invalid skill slug")
ErrSkillSlugConflict = errors.New("slug conflicts with a system skill")
ErrSkillGuardRejected = errors.New("skill content failed security scan")
)
// CreateFromContent creates a new managed skill from a single SKILL.md's
// content. This is the single-file equivalent of the web UI's ZIP-based
// skill upload (SkillsHandler.handleUpload in
// internal/http/skills_upload.go) — same frontmatter parsing, security
// guard, slug validation, and version-1 directory layout, minus multi-file
// extraction and dependency scanning (not meaningful for a text-only
// caller like an MCP tool). Name/slug/description are parsed from the
// content's YAML frontmatter.
func CreateFromContent(ctx context.Context, manage store.SkillManageStore, tenantSkillsDir, content, ownerID string) (id uuid.UUID, slug string, err error) {
if violations, safe := GuardSkillContent(content); !safe {
return uuid.Nil, "", fmt.Errorf("%w: %s", ErrSkillGuardRejected, FormatGuardViolations(violations))
}
name, description, parsedSlug, frontmatter := ParseSkillFrontmatter(content)
if name == "" {
return uuid.Nil, "", ErrSkillNameRequired
}
slug = parsedSlug
if slug == "" {
slug = Slugify(name)
}
if !SlugRegexp.MatchString(slug) {
return uuid.Nil, "", ErrSkillSlugInvalid
}
if manage.IsSystemSkill(slug) {
return uuid.Nil, "", ErrSkillSlugConflict
}
version := manage.GetNextVersion(ctx, slug)
destDir := filepath.Join(tenantSkillsDir, slug, strconv.Itoa(version))
if err := os.MkdirAll(destDir, 0o755); err != nil {
return uuid.Nil, "", err
}
if err := os.WriteFile(filepath.Join(destDir, "SKILL.md"), []byte(content), 0o644); err != nil {
_ = os.RemoveAll(destDir)
return uuid.Nil, "", err
}
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
desc := description
id, err = manage.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: name,
Slug: slug,
Description: &desc,
OwnerID: ownerID,
Visibility: "internal",
Status: "active",
Version: version,
FilePath: destDir,
FileSize: int64(len(content)),
FileHash: &hash,
Frontmatter: frontmatter,
})
if err != nil {
_ = os.RemoveAll(destDir)
return uuid.Nil, "", err
}
manage.BumpVersion()
return id, slug, nil
}