mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
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:
23 files changed
+2774
-49
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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()})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user