Skip to content

Commit dc605b9

Browse files
bclermontclaude
andauthored
feat(mcp): expand CRUD server to near-complete CLI parity (#1440)
* feat(mcp): expand CRUD MCP server to near-complete CLI parity Closes the CLI-vs-MCP tool coverage gap identified by auditing every `goclaw` CLI command against the existing goclaw_* MCP tool set. Adds skill grant/revoke, agent skill pin/unpin, and full CRUD/inspection surfaces for memory, knowledge graph (including dedup/merge/prune), tenants, providers, LLM traces, channel contacts, pending messages, audit activity, system config, tenant storage (list/size/delete/move), scoped agent config export/import, secure-CLI binary registry, and a DB-backed health check. Deliberately out of scope, documented inline where relevant: - `goclaw credentials`: confirmed CLI-local (~/.goclaw/config.yaml + keychain), no server resource to wrap. goclaw_secure_cli_binaries_* covers the closest real, previously-uncovered server resource instead. - Full tar-archive agent export/import (KG + workspace files): the CLI's version streams a multi-section archive with progress events, a shape that doesn't map to a single MCP tool call. Config + context files (the portable "brain") is covered. - `kg extract` (LLM-driven text extraction): goclaw_kg_ingest accepts the same Entity/Relation shapes the extractor produces, so a caller can run extraction itself and hand off the result. Wires 8 new store dependencies (Memory, KnowledgeGraph, Tracing, Contacts, PendingMessages, Activity, SystemConfigs, SecureCLI) through gateway.Server setters -> cmd/gateway.go -> CRUDDeps, following the existing Providers/Tenants pattern. Storage and secure-CLI-binary handlers duplicate internal/http's path-escape/symlink-hiding validation logic (documented inline) since internal/http already imports internal/mcp and the reverse would cycle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(mcp): return sessionKey in goclaw_chat_send response for persistent conversations The goclaw_chat_send tool creates a new session internally when sessionKey is empty, but never returned the key to the caller. This prevented using goclaw_chat_history to fetch previous messages in the session. Add SessionKey field to ChatSendResult so callers can: 1. Start a new agent chat without providing sessionKey 2. Receive the sessionKey back in the response 3. Use that sessionKey for follow-up messages and history queries Fixes the training loop pattern: start chat → get sessionKey → call goclaw_chat_history with that key → iterate skill based on actual failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(mcp): add timing diagnostics to goclaw_chat_send for timeout troubleshooting Log request arrival, processing duration, and errors with millisecond precision. Helps identify whether timeouts occur at MCP client→goclaw, goclaw→ollama, or during agent execution. Critical for production debugging. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(gateway): add http.Server timeouts for defensive timeout handling Set explicit timeouts in http.Server: - ReadTimeout: 1h (allow large uploads, long-running agent operations) - WriteTimeout: 1h (allow streaming responses to slow clients) - IdleTimeout: 30s (close idle keep-alive connections quickly) Provides defense-in-depth when Nginx/Traefik timeouts are misconfigured. Matches Nginx timeout (3600s) to prevent race conditions. Timeout chain: traefik 3600s = nginx 3600s = goclaw 3600s Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 0224959 commit dc605b9

23 files changed

Lines changed: 2774 additions & 49 deletions

cmd/gateway.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,30 @@ func runGateway() {
538538
if pgStores.Tenants != nil {
539539
server.SetTenantStore(pgStores.Tenants)
540540
}
541+
if pgStores.Memory != nil {
542+
server.SetMemoryStore(pgStores.Memory)
543+
}
544+
if pgStores.KnowledgeGraph != nil {
545+
server.SetKnowledgeGraphStore(pgStores.KnowledgeGraph)
546+
}
547+
if pgStores.Tracing != nil {
548+
server.SetTracingStore(pgStores.Tracing)
549+
}
550+
if pgStores.Contacts != nil {
551+
server.SetContactStore(pgStores.Contacts)
552+
}
553+
if pgStores.PendingMessages != nil {
554+
server.SetPendingMessageStore(pgStores.PendingMessages)
555+
}
556+
if pgStores.Activity != nil {
557+
server.SetActivityStore(pgStores.Activity)
558+
}
559+
if pgStores.SystemConfigs != nil {
560+
server.SetSystemConfigStore(pgStores.SystemConfigs)
561+
}
562+
if pgStores.SecureCLI != nil {
563+
server.SetSecureCLIStore(pgStores.SecureCLI)
564+
}
541565
server.SetSQLDB(pgStores.DB)
542566

543567
// Build OAuth token refresher before wireExtras so the resolver can inject tokens.

internal/gateway/chat_runner.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,18 @@ func (r *agentChatRunner) Send(ctx context.Context, agentID, sessionKey, message
6767
})
6868
if err != nil {
6969
if ctx.Err() != nil {
70-
return &mcpbridge.ChatSendResult{Cancelled: true}, nil
70+
return &mcpbridge.ChatSendResult{Cancelled: true, SessionKey: sessionKey}, nil
7171
}
7272
return nil, fmt.Errorf("run agent %q: %w", agentID, err)
7373
}
7474

7575
return &mcpbridge.ChatSendResult{
76-
RunID: result.RunID,
77-
Content: result.Content,
78-
Usage: result.Usage,
79-
Thinking: result.Thinking,
80-
Media: result.Media,
76+
RunID: result.RunID,
77+
SessionKey: sessionKey,
78+
Content: result.Content,
79+
Usage: result.Usage,
80+
Thinking: result.Thinking,
81+
Media: result.Media,
8182
}, nil
8283
}
8384

internal/gateway/server.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ type Server struct {
7777
quotaChecker *channels.QuotaChecker
7878
sqlDB *sql.DB // for the CRUD MCP server's quota usage tool (today's trace summary)
7979
tenantStore store.TenantStore // for the CRUD MCP server's "X-GoClaw-Tenant-Id" header resolution
80+
memoryStore store.MemoryStore
81+
kgStore store.KnowledgeGraphStore
82+
tracingStore store.TracingStore
83+
contactStore store.ContactStore
84+
pendingMsgStore store.PendingMessageStore
85+
activityStore store.ActivityStore
86+
systemCfgStore store.SystemConfigStore
87+
secureCLIStore store.SecureCLIStore
8088

8189
// Phase 3 CRUD MCP server (/api/mcp/) dependencies: chat/LLM/logs/send/voices.
8290
llmProviders *providers.Registry
@@ -317,6 +325,14 @@ func (s *Server) BuildMux() *http.ServeMux {
317325
VoiceCache: s.voiceCache,
318326
VoiceSecretsStore: s.voiceSecretsStore,
319327
Tenants: s.tenantStore,
328+
Memory: s.memoryStore,
329+
KnowledgeGraph: s.kgStore,
330+
Tracing: s.tracingStore,
331+
Contacts: s.contactStore,
332+
PendingMessages: s.pendingMsgStore,
333+
Activity: s.activityStore,
334+
SystemConfigs: s.systemCfgStore,
335+
SecureCLI: s.secureCLIStore,
320336
}, s.version)
321337
mux.Handle("/api/mcp/", mcpServerTokenAuthMiddleware(s.cfg.Gateway.MCPServerToken, crudHandler))
322338
} else {
@@ -496,8 +512,11 @@ func (s *Server) Start(ctx context.Context) error {
496512

497513
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
498514
s.httpServer = &http.Server{
499-
Addr: addr,
500-
Handler: handler,
515+
Addr: addr,
516+
Handler: handler,
517+
ReadTimeout: 3600 * time.Second, // 1h: allow large uploads, long-running reads
518+
WriteTimeout: 3600 * time.Second, // 1h: allow streaming responses, slow clients
519+
IdleTimeout: 30 * time.Second, // 30s: close idle connections
501520
}
502521

503522
slog.Info("gateway starting", "addr", addr)
@@ -867,6 +886,41 @@ func (s *Server) SetProviderStore(ps store.ProviderStore) { s.providerStore = ps
867886
// request header (UUID or slug) to a concrete tenant for every CRUD MCP call.
868887
func (s *Server) SetTenantStore(ts store.TenantStore) { s.tenantStore = ts }
869888

889+
// SetMemoryStore sets the memory store, used by the CRUD MCP server (see
890+
// internal/mcp/crud_server.go) to expose goclaw_memory_* tools.
891+
func (s *Server) SetMemoryStore(ms store.MemoryStore) { s.memoryStore = ms }
892+
893+
// SetKnowledgeGraphStore sets the knowledge graph store, used by the CRUD
894+
// MCP server (see internal/mcp/crud_server.go) to expose goclaw_kg_* tools.
895+
func (s *Server) SetKnowledgeGraphStore(kg store.KnowledgeGraphStore) { s.kgStore = kg }
896+
897+
// SetTracingStore sets the LLM call tracing store, used by the CRUD MCP
898+
// server (see internal/mcp/crud_server.go) to expose goclaw_traces_* tools.
899+
func (s *Server) SetTracingStore(ts store.TracingStore) { s.tracingStore = ts }
900+
901+
// SetContactStore sets the channel contact store, used by the CRUD MCP
902+
// server (see internal/mcp/crud_server.go) to expose goclaw_contacts_* tools.
903+
func (s *Server) SetContactStore(cs store.ContactStore) { s.contactStore = cs }
904+
905+
// SetPendingMessageStore sets the pending-message store, used by the CRUD
906+
// MCP server (see internal/mcp/crud_server.go) to expose
907+
// goclaw_pending_messages_* tools.
908+
func (s *Server) SetPendingMessageStore(pm store.PendingMessageStore) { s.pendingMsgStore = pm }
909+
910+
// SetActivityStore sets the audit-log store, used by the CRUD MCP server
911+
// (see internal/mcp/crud_server.go) to expose goclaw_activity_list.
912+
func (s *Server) SetActivityStore(as store.ActivityStore) { s.activityStore = as }
913+
914+
// SetSystemConfigStore sets the system config store, used by the CRUD MCP
915+
// server (see internal/mcp/crud_server.go) to expose goclaw_system_config_*
916+
// tools.
917+
func (s *Server) SetSystemConfigStore(sc store.SystemConfigStore) { s.systemCfgStore = sc }
918+
919+
// SetSecureCLIStore sets the secure-CLI binary registry store, used by the
920+
// CRUD MCP server (see internal/mcp/crud_server.go) to expose
921+
// goclaw_secure_cli_binaries_* tools.
922+
func (s *Server) SetSecureCLIStore(sc store.SecureCLIStore) { s.secureCLIStore = sc }
923+
870924
// SetExecApprovalManager sets the exec approval manager, used by the CRUD MCP
871925
// server (see internal/mcp/crud_server.go) to expose exec approval tools.
872926
func (s *Server) SetExecApprovalManager(m *tools.ExecApprovalManager) { s.execApprovalMgr = m }

internal/mcp/crud_activity.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
6+
mcpgo "github.com/mark3labs/mcp-go/mcp"
7+
mcpserver "github.com/mark3labs/mcp-go/server"
8+
9+
"github.com/nextlevelbuilder/goclaw/internal/store"
10+
)
11+
12+
// registerActivityCRUDTools registers goclaw_activity_list, backed by
13+
// store.ActivityStore — closes a CLI-vs-MCP coverage gap (`goclaw activity
14+
// list`, the audit log of admin/agent actions emitted via emitAudit
15+
// throughout internal/http).
16+
func registerActivityCRUDTools(srv *mcpserver.MCPServer, activity store.ActivityStore) {
17+
srv.AddTool(mcpgo.NewTool("goclaw_activity_list",
18+
mcpgo.WithDescription("List audit log entries (admin/agent actions), optionally filtered."),
19+
mcpgo.WithString("actor_type", mcpgo.Description("Filter by actor type (e.g. \"user\", \"agent\", \"system\").")),
20+
mcpgo.WithString("actor_id", mcpgo.Description("Filter by actor ID.")),
21+
mcpgo.WithString("action", mcpgo.Description("Filter by action name (e.g. \"skill.file_updated\").")),
22+
mcpgo.WithString("entity_type", mcpgo.Description("Filter by entity type (e.g. \"skill\", \"agent\").")),
23+
mcpgo.WithString("entity_id", mcpgo.Description("Filter by entity ID.")),
24+
mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return; defaults to 50.")),
25+
mcpgo.WithNumber("offset", mcpgo.Description("Pagination offset.")),
26+
mcpgo.WithReadOnlyHintAnnotation(true),
27+
), handleActivityList(activity))
28+
}
29+
30+
func handleActivityList(activity store.ActivityStore) mcpserver.ToolHandlerFunc {
31+
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
32+
opts := store.ActivityListOpts{
33+
ActorType: req.GetString("actor_type", ""),
34+
ActorID: req.GetString("actor_id", ""),
35+
Action: req.GetString("action", ""),
36+
EntityType: req.GetString("entity_type", ""),
37+
EntityID: req.GetString("entity_id", ""),
38+
Limit: intArg(req, "limit", 50),
39+
Offset: intArg(req, "offset", 0),
40+
}
41+
list, err := activity.List(ctx, opts)
42+
if err != nil {
43+
return toolError("activity.list", err)
44+
}
45+
total, err := activity.Count(ctx, opts)
46+
if err != nil {
47+
return toolError("activity.list", err)
48+
}
49+
return jsonToolResult(map[string]any{"entries": list, "total": total})
50+
}
51+
}

internal/mcp/crud_agents.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mcp
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"slices"
78
"strings"
@@ -446,3 +447,143 @@ func handleAgentsFilesSet(agents store.AgentStore) mcpserver.ToolHandlerFunc {
446447
})
447448
}
448449
}
450+
451+
// maxPinnedSkillsPerAgent mirrors the invariant documented on
452+
// store.AgentData.ParsePinnedSkills (agent_store.go) — pinned skills are
453+
// always-loaded into every turn's system prompt, so the count is capped to
454+
// bound prompt size.
455+
const maxPinnedSkillsPerAgent = 10
456+
457+
// registerAgentSkillPinCRUDTools registers goclaw_agents_pin_skill/
458+
// goclaw_agents_unpin_skill. Pinning is distinct from granting access
459+
// (registerSkillGrantCRUDTools in crud_skills.go): a pinned skill is
460+
// auto-loaded into the agent's system prompt every turn (see
461+
// internal/agent/resolver.go's ParsePinnedSkills / PinnedSkillsSummary),
462+
// while a grant only makes the skill available for on-demand use_skill
463+
// calls. There is no dedicated pin/unpin RPC on the gateway — the web UI
464+
// sets other_config.pinned_skills via the general agents.update WS method
465+
// (internal/gateway/methods/agents_update.go), which replaces the whole
466+
// other_config JSONB blob wholesale. These handlers replicate that by
467+
// reading the agent's current other_config, splicing pinned_skills, and
468+
// writing the full blob back — a naive partial write would silently drop
469+
// every other other_config field (self_evolution_metrics, tts_params, etc).
470+
func registerAgentSkillPinCRUDTools(srv *mcpserver.MCPServer, agents store.AgentStore) {
471+
srv.AddTool(mcpgo.NewTool("goclaw_agents_pin_skill",
472+
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)."),
473+
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")),
474+
mcpgo.WithString("skill", mcpgo.Required(), mcpgo.Description("Skill slug/name to pin.")),
475+
), handleAgentsPinSkill(agents))
476+
477+
srv.AddTool(mcpgo.NewTool("goclaw_agents_unpin_skill",
478+
mcpgo.WithDescription("Unpin a skill from an agent (does not revoke access, only removes it from the always-loaded set)."),
479+
mcpgo.WithString("id", mcpgo.Required(), mcpgo.Description("Agent UUID.")),
480+
mcpgo.WithString("skill", mcpgo.Required(), mcpgo.Description("Skill slug/name to unpin.")),
481+
), handleAgentsUnpinSkill(agents))
482+
}
483+
484+
// spliceOtherConfigPinnedSkills reads ag's other_config JSONB, applies edit
485+
// to its pinned_skills list, and returns the full re-marshaled blob ready
486+
// to pass as agents.Update's "other_config" value (a full-blob replace, not
487+
// a merge — see registerAgentSkillPinCRUDTools doc comment).
488+
func spliceOtherConfigPinnedSkills(ag store.AgentData, edit func(current []string) ([]string, error)) (json.RawMessage, error) {
489+
bag := map[string]any{}
490+
if len(ag.OtherConfig) > 0 {
491+
if err := json.Unmarshal(ag.OtherConfig, &bag); err != nil {
492+
return nil, fmt.Errorf("cannot parse existing other_config: %w", err)
493+
}
494+
}
495+
next, err := edit(ag.ParsePinnedSkills())
496+
if err != nil {
497+
return nil, err
498+
}
499+
bag["pinned_skills"] = next
500+
return json.Marshal(bag)
501+
}
502+
503+
func handleAgentsPinSkill(agents store.AgentStore) mcpserver.ToolHandlerFunc {
504+
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
505+
idStr, err := req.RequireString("id")
506+
if err != nil {
507+
return toolError("agents.pin_skill", err)
508+
}
509+
id, err := uuid.Parse(idStr)
510+
if err != nil {
511+
return toolError("agents.pin_skill", fmt.Errorf("invalid id: %w", err))
512+
}
513+
skill, err := req.RequireString("skill")
514+
if err != nil {
515+
return toolError("agents.pin_skill", err)
516+
}
517+
518+
ag, err := agents.GetByID(ctx, id)
519+
if err != nil {
520+
return toolError("agents.pin_skill", err)
521+
}
522+
523+
raw, err := spliceOtherConfigPinnedSkills(*ag, func(current []string) ([]string, error) {
524+
if slices.Contains(current, skill) {
525+
return current, nil
526+
}
527+
if len(current) >= maxPinnedSkillsPerAgent {
528+
return nil, fmt.Errorf("agent already has %d pinned skills (max %d)", len(current), maxPinnedSkillsPerAgent)
529+
}
530+
return append(current, skill), nil
531+
})
532+
if err != nil {
533+
return toolError("agents.pin_skill", err)
534+
}
535+
536+
if err := agents.Update(ctx, id, map[string]any{"other_config": []byte(raw)}); err != nil {
537+
return toolError("agents.pin_skill", err)
538+
}
539+
updated, err := agents.GetByID(ctx, id)
540+
if err != nil {
541+
return toolError("agents.pin_skill", err)
542+
}
543+
return jsonToolResult(map[string]any{"ok": "true", "pinned_skills": updated.ParsePinnedSkills()})
544+
}
545+
}
546+
547+
func handleAgentsUnpinSkill(agents store.AgentStore) mcpserver.ToolHandlerFunc {
548+
return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {
549+
idStr, err := req.RequireString("id")
550+
if err != nil {
551+
return toolError("agents.unpin_skill", err)
552+
}
553+
id, err := uuid.Parse(idStr)
554+
if err != nil {
555+
return toolError("agents.unpin_skill", fmt.Errorf("invalid id: %w", err))
556+
}
557+
skill, err := req.RequireString("skill")
558+
if err != nil {
559+
return toolError("agents.unpin_skill", err)
560+
}
561+
562+
ag, err := agents.GetByID(ctx, id)
563+
if err != nil {
564+
return toolError("agents.unpin_skill", err)
565+
}
566+
567+
raw, err := spliceOtherConfigPinnedSkills(*ag, func(current []string) ([]string, error) {
568+
out := make([]string, 0, len(current))
569+
for _, s := range current {
570+
if s != skill {
571+
out = append(out, s)
572+
}
573+
}
574+
return out, nil
575+
})
576+
if err != nil {
577+
return toolError("agents.unpin_skill", err)
578+
}
579+
580+
if err := agents.Update(ctx, id, map[string]any{"other_config": []byte(raw)}); err != nil {
581+
return toolError("agents.unpin_skill", err)
582+
}
583+
updated, err := agents.GetByID(ctx, id)
584+
if err != nil {
585+
return toolError("agents.unpin_skill", err)
586+
}
587+
return jsonToolResult(map[string]any{"ok": "true", "pinned_skills": updated.ParsePinnedSkills()})
588+
}
589+
}

0 commit comments

Comments
 (0)