From dc605b911e67316f623adcb251b973688f455488 Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Wed, 15 Jul 2026 14:56:03 -0400 Subject: [PATCH] feat(mcp): expand CRUD server to near-complete CLI parity (#1440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Bruno Clermont Co-authored-by: Claude Sonnet 5 --- cmd/gateway.go | 24 ++ internal/gateway/chat_runner.go | 13 +- internal/gateway/server.go | 58 +++- internal/mcp/crud_activity.go | 51 +++ internal/mcp/crud_agents.go | 141 ++++++++ internal/mcp/crud_agents_export.go | 141 ++++++++ internal/mcp/crud_chat.go | 11 + internal/mcp/crud_contacts.go | 146 ++++++++ internal/mcp/crud_fakes_test.go | 2 +- internal/mcp/crud_health.go | 42 +++ internal/mcp/crud_helpers.go | 13 +- internal/mcp/crud_knowledge_graph.go | 466 ++++++++++++++++++++++++++ internal/mcp/crud_memory.go | 157 +++++++++ internal/mcp/crud_pending_messages.go | 81 +++++ internal/mcp/crud_providers.go | 195 +++++++++++ internal/mcp/crud_secure_cli.go | 169 ++++++++++ internal/mcp/crud_server.go | 69 ++++ internal/mcp/crud_skills.go | 205 +++++++++-- internal/mcp/crud_storage.go | 400 ++++++++++++++++++++++ internal/mcp/crud_system_config.go | 93 +++++ internal/mcp/crud_tenants.go | 179 ++++++++++ internal/mcp/crud_traces.go | 83 +++++ internal/skills/create_file.go | 84 +++++ 23 files changed, 2774 insertions(+), 49 deletions(-) create mode 100644 internal/mcp/crud_activity.go create mode 100644 internal/mcp/crud_agents_export.go create mode 100644 internal/mcp/crud_contacts.go create mode 100644 internal/mcp/crud_health.go create mode 100644 internal/mcp/crud_knowledge_graph.go create mode 100644 internal/mcp/crud_memory.go create mode 100644 internal/mcp/crud_pending_messages.go create mode 100644 internal/mcp/crud_providers.go create mode 100644 internal/mcp/crud_secure_cli.go create mode 100644 internal/mcp/crud_storage.go create mode 100644 internal/mcp/crud_system_config.go create mode 100644 internal/mcp/crud_tenants.go create mode 100644 internal/mcp/crud_traces.go create mode 100644 internal/skills/create_file.go diff --git a/cmd/gateway.go b/cmd/gateway.go index 80364ae6..e16a4c57 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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. diff --git a/internal/gateway/chat_runner.go b/internal/gateway/chat_runner.go index 130a0232..87fc5b36 100644 --- a/internal/gateway/chat_runner.go +++ b/internal/gateway/chat_runner.go @@ -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 } diff --git a/internal/gateway/server.go b/internal/gateway/server.go index c5c354fd..9c1499bc 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -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 } diff --git a/internal/mcp/crud_activity.go b/internal/mcp/crud_activity.go new file mode 100644 index 00000000..9e828afd --- /dev/null +++ b/internal/mcp/crud_activity.go @@ -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}) + } +} diff --git a/internal/mcp/crud_agents.go b/internal/mcp/crud_agents.go index 51b5a399..cb206177 100644 --- a/internal/mcp/crud_agents.go +++ b/internal/mcp/crud_agents.go @@ -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()}) + } +} diff --git a/internal/mcp/crud_agents_export.go b/internal/mcp/crud_agents_export.go new file mode 100644 index 00000000..a28e95f9 --- /dev/null +++ b/internal/mcp/crud_agents_export.go @@ -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}) + } +} diff --git a/internal/mcp/crud_chat.go b/internal/mcp/crud_chat.go index fdaed678..40ed7473 100644 --- a/internal/mcp/crud_chat.go +++ b/internal/mcp/crud_chat.go @@ -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) } } diff --git a/internal/mcp/crud_contacts.go b/internal/mcp/crud_contacts.go new file mode 100644 index 00000000..37756859 --- /dev/null +++ b/internal/mcp/crud_contacts.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_fakes_test.go b/internal/mcp/crud_fakes_test.go index a37fb981..04b95e87 100644 --- a/internal/mcp/crud_fakes_test.go +++ b/internal/mcp/crud_fakes_test.go @@ -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) { diff --git a/internal/mcp/crud_health.go b/internal/mcp/crud_health.go new file mode 100644 index 00000000..7a574bff --- /dev/null +++ b/internal/mcp/crud_health.go @@ -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) + } +} diff --git a/internal/mcp/crud_helpers.go b/internal/mcp/crud_helpers.go index 3cb7c14a..a795ce2b 100644 --- a/internal/mcp/crud_helpers.go +++ b/internal/mcp/crud_helpers.go @@ -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 diff --git a/internal/mcp/crud_knowledge_graph.go b/internal/mcp/crud_knowledge_graph.go new file mode 100644 index 00000000..74a0983e --- /dev/null +++ b/internal/mcp/crud_knowledge_graph.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_memory.go b/internal/mcp/crud_memory.go new file mode 100644 index 00000000..d7a741ef --- /dev/null +++ b/internal/mcp/crud_memory.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_pending_messages.go b/internal/mcp/crud_pending_messages.go new file mode 100644 index 00000000..7b163c6d --- /dev/null +++ b/internal/mcp/crud_pending_messages.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_providers.go b/internal/mcp/crud_providers.go new file mode 100644 index 00000000..98608c56 --- /dev/null +++ b/internal/mcp/crud_providers.go @@ -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}) + } +} diff --git a/internal/mcp/crud_secure_cli.go b/internal/mcp/crud_secure_cli.go new file mode 100644 index 00000000..4d10a79b --- /dev/null +++ b/internal/mcp/crud_secure_cli.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_server.go b/internal/mcp/crud_server.go index 71ccf72e..12f7cfb1 100644 --- a/internal/mcp/crud_server.go +++ b/internal/mcp/crud_server.go @@ -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 diff --git a/internal/mcp/crud_skills.go b/internal/mcp/crud_skills.go index d0683342..fc292541 100644 --- a/internal/mcp/crud_skills.go +++ b/internal/mcp/crud_skills.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_storage.go b/internal/mcp/crud_storage.go new file mode 100644 index 00000000..67474e8b --- /dev/null +++ b/internal/mcp/crud_storage.go @@ -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}) + } +} diff --git a/internal/mcp/crud_system_config.go b/internal/mcp/crud_system_config.go new file mode 100644 index 00000000..e2bad513 --- /dev/null +++ b/internal/mcp/crud_system_config.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_tenants.go b/internal/mcp/crud_tenants.go new file mode 100644 index 00000000..63d58485 --- /dev/null +++ b/internal/mcp/crud_tenants.go @@ -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"}) + } +} diff --git a/internal/mcp/crud_traces.go b/internal/mcp/crud_traces.go new file mode 100644 index 00000000..6e3230c3 --- /dev/null +++ b/internal/mcp/crud_traces.go @@ -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}) + } +} diff --git a/internal/skills/create_file.go b/internal/skills/create_file.go new file mode 100644 index 00000000..74159744 --- /dev/null +++ b/internal/skills/create_file.go @@ -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 +}