Files
goclaw/internal/mcp/crud_knowledge_graph.go
dc605b911e 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>
2026-07-16 01:56:03 +07:00

467 lines
19 KiB
Go

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"})
}
}