package mcp import ( "context" "encoding/json" "fmt" "strings" "github.com/dtoro/oikos/internal/audit" "github.com/dtoro/oikos/internal/checkdefaults" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/policy" "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" ) // toolReg pairs a tool definition with its handler. allTools returns a slice // of these; newServer iterates it and registers each one wrapped with // withActivityLogging. type toolReg struct { tool *mcp.Tool handler toolHandler } // allTools returns every MCP tool registration. Tool definitions, schemas, // descriptions, and handler bodies are kept verbatim from the former inline // newServer registrations. func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg { return []toolReg{ {tool: &mcp.Tool{Name: "get_entity", Description: "Get an entity by slug or UUID", InputSchema: objSchema(prop{"slug_or_id", "string", "Entity slug (e.g. host:hubris) or UUID"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) idOrSlug, _ := args["slug_or_id"].(string) return queryEntity(ctx, pool, idOrSlug), nil }}, {tool: &mcp.Tool{Name: "list_entities", Description: "List entities filtered by type, state, or search", InputSchema: objSchema( prop{"type", "string", "Filter by entity type"}, prop{"state", "string", "Filter by lifecycle state"}, prop{"q", "string", "Substring match on slug or name"}, prop{"limit", "integer", "Max rows (default 50)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at FROM entities e WHERE ($1::text IS NULL OR e.type = $1) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') ORDER BY e.slug LIMIT $4`, nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil }}, {tool: &mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", InputSchema: objSchema(prop{"entity_id", "string", "Entity slug"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_id"].(string) return queryRows(ctx, pool, ` SELECT r.type, src.slug AS source, tgt.slug AS target FROM relationships r JOIN entities src ON src.id = r.source_id JOIN entities tgt ON tgt.id = r.target_id WHERE (src.slug = $1 OR tgt.slug = $1) AND r.valid_to IS NULL ORDER BY r.type`, slug), nil }}, {tool: &mcp.Tool{Name: "get_blast_radius", Description: "Find entities affected if this entity goes down", InputSchema: objSchema( prop{"entity_id", "string", "Entity slug"}, prop{"depth", "integer", "Traversal depth (default 3)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_id"].(string) depth := int(getFloat(args, "depth", 3)) return queryRows(ctx, pool, "SELECT e.slug, CAST(b.depth AS int) FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2) b JOIN entities e ON e.id = b.entity_id", slug, depth), nil }}, {tool: &mcp.Tool{Name: "get_health_summary", Description: "Current fleet health summary", InputSchema: objSchema(), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return queryRows(ctx, pool, ` SELECT e.slug, e.type, st.health, st.last_check_at FROM entity_status st JOIN entities e ON e.id = st.entity_id WHERE e.type <> 'check' ORDER BY e.slug`), nil }}, {tool: &mcp.Tool{Name: "get_audit_trail", Description: "Query the audit log", InputSchema: objSchema(prop{"entity_id", "string", "Filter by affected entity UUID"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id FROM audit_log WHERE ($1::text IS NULL OR entity_id::text = $1) ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil }}, {tool: &mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.", InputSchema: objSchema(prop{"query", "string", "Search terms"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) q := nStr(args["query"]) return annotateJSONResult(queryRows(ctx, pool, ` SELECT ke.title, e.slug, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_headline('english', ke.content, plainto_tsquery('english', $1), 'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3, FragmentDelimiter=" ... "') AS snippet, ke.source, ke.tags FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE ke.search @@ plainto_tsquery('english', $1) ORDER BY rank DESC LIMIT 20`, q), "knowledge_results"), nil }}, {tool: &mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.", InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_slug"].(string) return annotateJSONResult(queryRows(ctx, pool, ` SELECT ke.title, ke.source, e.type AS kind, e.slug, ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id JOIN relationships r ON r.source_id = ke.entity_id JOIN entities target ON target.id = r.target_id WHERE target.slug = $1 AND r.valid_to IS NULL AND r.type IN ('documents', 'about') UNION SELECT ke.title, ke.source, e.type AS kind, e.slug, ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id JOIN relationships r ON r.source_id = ke.entity_id JOIN entity_types target_type ON target_type.name = (SELECT type FROM entities WHERE slug = $1) JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 WHERE r.valid_to IS NULL AND r.type = 'procedure-for' ORDER BY 1`, slug), "knowledge_results"), nil }}, {tool: &mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.", InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["slug"].(string) return queryRows(ctx, pool, ` SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text FROM knowledge_entities ke JOIN entities e ON e.id = ke.entity_id WHERE e.slug = $1`, slug), nil }}, {tool: &mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.", InputSchema: objSchema( prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."}, prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."}, prop{"about", "string", "Optional entity slug(s) this knowledge concerns. Pass a single slug (e.g. 'lxc:nfs-export') or a JSON array of slugs (e.g. '[\"lxc:nfs-export\", \"lxc:gitea\"]') to link to multiple entities. get_entity_knowledge surfaces it for each."}, prop{"tags", "string", "Optional comma-separated tags (e.g. 'docker,networking,gotcha')."}, prop{"kind", "string", "One of: investigation (a finding/incident analysis — default), document (reference), runbook (a repeatable procedure)."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return upsertKnowledge(ctx, pool, args) }}, {tool: &mcp.Tool{Name: "create_entity", Description: "Create a new entity in the knowledge graph — the creation half alongside update_entity_attributes (which only updates EXISTING entities). Use it when a task needs an entity that does not exist yet: a new check (check:::), an ingress (ingress:), a cert (cert:), a service, a host/LXC/VM, etc. After inserting, it derives default checks from the entity type's monitoring spec (same as a seed ingest), so creating a checkable entity wires its monitoring in one call. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). If the slug already exists it returns 'already exists' — then use update_entity_attributes to change it.", InputSchema: objSchema( prop{"type", "string", "Entity type — must already exist in the ontology and not be abstract (e.g. service, lxc, host, vm, check, ingress, cert, dns)."}, prop{"name", "string", "Human-readable name (e.g. 'HAOS http service check')."}, prop{"slug", "string", "Entity slug (e.g. check:http:service:haos:0, ingress:home.hubris.network). If omitted, defaults to :."}, prop{"attributes", "string", "JSON object string of attributes, e.g. {\"check_type\":\"http:service\",\"target\":\"service:haos\",\"port\":\"8123\"}. Optional."}, prop{"state", "string", "Lifecycle state. Optional; defaults to the type's lifecycle default_state."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) entityType, _ := args["type"].(string) name, _ := args["name"].(string) slug, _ := args["slug"].(string) if slug == "" && entityType != "" && name != "" { slug = entityType + ":" + name } if entityType == "" || name == "" || slug == "" { return textResult("error: type and name are required (slug defaults to :)"), nil } attrsStr, _ := args["attributes"].(string) attrs := map[string]any{} if attrsStr != "" { if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil { return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil } } attrsJSON, _ := json.Marshal(attrs) stateStr, _ := args["state"].(string) tx, err := pool.Begin(ctx) if err != nil { return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil } defer tx.Rollback(ctx) // Validate the type exists and is concrete (mirror httpapi.CreateEntity). var isAbstract bool if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil { return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil } if isAbstract { return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil } // Default state from the type's lifecycle unless the caller // supplied one. Caller-supplied states are validated against // the lifecycle's declared states — a create_entity bypass of // lifecycle guardrails would let an agent create in a terminal // state (destroyed) without satisfying the preconditions that // set_entity_state enforces for the same transition. var state *string var lsDefault, statesRaw string if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'') FROM lifecycle_defs ld JOIN entity_types et ON et.lifecycle_id = ld.id WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil { var validStates []string json.Unmarshal([]byte(statesRaw), &validStates) if stateStr != "" { found := false for _, s := range validStates { if s == stateStr { found = true break } } if !found && len(validStates) > 0 { return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil } state = &stateStr } else if lsDefault != "" { state = &lsDefault } } id, err := uuid.NewV7() if err != nil { return textResult(fmt.Sprintf("error: gen id: %v", err)), nil } var createdName string if err := tx.QueryRow(ctx, ` INSERT INTO entities (id, slug, type, name, state, attributes) VALUES ($1, $2, $3, $4, $5, $6) RETURNING name`, id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil { if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil } return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil } res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON) if derr != nil { return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil } if cerr := tx.Commit(ctx); cerr != nil { return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil } return textResult(formatCreateResult(slug, entityType, res)), nil }}, {tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.", InputSchema: objSchema( prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."}, prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["slug"].(string) attrsStr, _ := args["attributes"].(string) if slug == "" || attrsStr == "" { return textResult("error: slug and attributes are required"), nil } var attrs map[string]any if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil { return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil } attrsJSON, _ := json.Marshal(attrs) // Run the merge + check regeneration in one transaction so the // derived checks always see the post-merge attributes. Mirrors // httpapi.PatchEntity; without this, setting an entity's // `monitoring` attribute via MCP silently produced no checks. tx, err := pool.Begin(ctx) if err != nil { return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil } defer tx.Rollback(ctx) ct, err := tx.Exec(ctx, ` UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE slug = $1`, slug, string(attrsJSON)) if err != nil { return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil } if ct.RowsAffected() == 0 { return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil } var id uuid.UUID var entityType, name string var mergedAttrs []byte if err := tx.QueryRow(ctx, `SELECT id, type, name, attributes FROM entities WHERE slug = $1`, slug). Scan(&id, &entityType, &name, &mergedAttrs); err != nil { return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil } res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs) if cerr != nil { return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil } if cerr := tx.Commit(ctx); cerr != nil { return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil } return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil }}, {tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).", InputSchema: objSchema( prop{"slug", "string", "Entity slug."}, prop{"state", "string", "Target lifecycle state."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["slug"].(string) targetState, _ := args["state"].(string) if slug == "" || targetState == "" { return textResult("error: slug and state are required"), nil } tx, err := pool.Begin(ctx) if err != nil { return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil } defer tx.Rollback(ctx) var id uuid.UUID var entityType, currentState string if err := tx.QueryRow(ctx, `SELECT id, type, coalesce(state,'') FROM entities WHERE slug = $1`, slug). Scan(&id, &entityType, ¤tState); err != nil { return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil } if err := db.ValidateTransition(ctx, tx, id, entityType, currentState, targetState); err != nil { return textResult(fmt.Sprintf("error: %v", err)), nil } ct, err := tx.Exec(ctx, `UPDATE entities SET state = $2, updated_at = now() WHERE id = $1`, id, targetState) if err != nil { return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil } if ct.RowsAffected() == 0 { return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil } if err := tx.Commit(ctx); err != nil { return textResult(fmt.Sprintf("error: commit: %v", err)), nil } return textResult(fmt.Sprintf("Transitioned %s: %s → %s.", slug, currentState, targetState)), nil }}, {tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.", InputSchema: objSchema( prop{"source", "string", "Source entity slug."}, prop{"target", "string", "Target entity slug."}, prop{"type", "string", "Relationship type name (must already exist in the ontology)."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) source, _ := args["source"].(string) target, _ := args["target"].(string) relType, _ := args["type"].(string) if source == "" || target == "" || relType == "" { return textResult("error: source, target, and type are required"), nil } var sourceID, targetID uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil { return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil } if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil { return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil } _, err := pool.Exec(ctx, ` INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now() WHERE NOT EXISTS ( SELECT 1 FROM relationships WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`, sourceID, targetID, relType) if err != nil { return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil } return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil }}, {tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.", InputSchema: objSchema( prop{"source", "string", "Source entity slug."}, prop{"target", "string", "Target entity slug."}, prop{"type", "string", "Relationship type name."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) source, _ := args["source"].(string) target, _ := args["target"].(string) relType, _ := args["type"].(string) if source == "" || target == "" || relType == "" { return textResult("error: source, target, and type are required"), nil } var sourceID, targetID uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil { return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil } if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil { return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil } ct, err := pool.Exec(ctx, ` UPDATE relationships SET valid_to = now() WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`, sourceID, targetID, relType) if err != nil { return textResult(fmt.Sprintf("error ending relationship: %v", err)), nil } if ct.RowsAffected() == 0 { return textResult(fmt.Sprintf("No active relationship %s —%s→ %s found.", source, relType, target)), nil } return textResult(fmt.Sprintf("Ended: %s —%s→ %s.", source, relType, target)), nil }}, {tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics", InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) hours := int(getFloat(args, "hours", 24)) return annotateJSONResult(queryRows(ctx, pool, ` SELECT time_bucket('1 hour', ts) AS bucket, entity_id::text, metric, ROUND(avg(value)::numeric, 2) AS avg, ROUND(min(value)::numeric, 2) AS min, ROUND(max(value)::numeric, 2) AS max FROM metric_samples WHERE ts > now() - make_interval(hours => $1) GROUP BY bucket, entity_id, metric ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil }}, // ─── Phase 4: new tools ────────────────────────────────────────── {tool: &mcp.Tool{Name: "get_signal_history", Description: "Query open and recent signals", InputSchema: objSchema( prop{"entity_slug", "string", "Filter by target entity slug"}, prop{"state", "string", "Filter by signal state (raised, resolved)"}, prop{"limit", "integer", "Max rows (default 50)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) return queryRows(ctx, pool, ` SELECT s.entity_id::text, s.kind, s.severity, s.state, s.occurrence_count, e.slug AS target_slug, s.first_seen_at, s.last_seen_at FROM signals s LEFT JOIN entities e ON e.id = s.target_entity_id WHERE ($1::text IS NULL OR e.slug = $1) AND ($2::text IS NULL OR s.state = $2) ORDER BY s.last_seen_at DESC LIMIT $3`, nStr(args["entity_slug"]), nStr(args["state"]), limit), nil }}, {tool: &mcp.Tool{Name: "get_patterns", Description: "List learned action patterns", InputSchema: objSchema( prop{"status", "string", "Filter by status (hypothesized, validated, active)"}, prop{"entity_type", "string", "Filter by applies_type"}, prop{"action", "string", "Filter by action"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` SELECT p.entity_id::text, p.applies_type, p.action, p.pattern, p.confidence, p.evidence_count, p.success_count, p.failure_count, p.status, p.quarantined, p.version, p.last_validated_at FROM patterns p WHERE ($1::text IS NULL OR p.status = $1) AND ($2::text IS NULL OR p.applies_type = $2) AND ($3::text IS NULL OR p.action = $3) ORDER BY p.applies_type, p.action`, nStr(args["status"]), nStr(args["entity_type"]), nStr(args["action"])), nil }}, {tool: &mcp.Tool{Name: "get_skills", Description: "List available automation skills", InputSchema: objSchema( prop{"status", "string", "Filter by status (active, inactive, deprecated)"}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) return queryRows(ctx, pool, ` SELECT s.entity_id::text, s.version, s.name, LEFT(s.procedure::text, 300) AS procedure_preview, s.applies_type, s.action, s.status, s.success_rate, s.changed_by::text, s.change_reason, s.last_used_at FROM skills s WHERE ($1::text IS NULL OR s.status = $1) ORDER BY s.name, s.version DESC`, nStr(args["status"])), nil }}, // ── request_execution (legacy fixed enum) retired 2026-07-14 ── // All mutations now route through `run`. The handler functions // (runRexecRestart, runRexecSystemctl, etc.) are kept as reference // for future runbook extraction — especially pct_create DNS/VMID logic. // DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md. {tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.", InputSchema: objSchema( prop{"target", "string", "Target entity slug: host: (e.g. host:strong), lxc: (e.g. lxc:caddy), or vm: (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."}, prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."}, prop{"purpose", "string", "One sentence: why you're running this. Shown to the operator alongside the approval — be specific, this is what they're approving."}, prop{"declared_risk", "string", "Optional self-assessment: read_only, reversible_low, config_mutation, or destructive. This can only ESCALATE the automatic classification, never lower it — declaring a mutating command as read_only has no effect."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) targetSlug, _ := args["target"].(string) command, _ := args["command"].(string) purpose, _ := args["purpose"].(string) declaredRisk, _ := args["declared_risk"].(string) sessionID, _ := args["_session_id"].(string) if targetSlug == "" || command == "" { return textResult("error: target and command are required"), nil } var targetID uuid.UUID if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil { return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil } return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil }}, // inspect_path is the bulk fact-gathering tool from // plans/2026-07-18-session-review-three-sessions.md P1.5. // Sessions 1e9c7691 and 55927f0a each spent ~15 `run` calls // gathering identical facts (`mount | grep`, `df`, `ls -la`, // `stat`) across hosts and LXCs to understand where a path // lives, who mounts it, and what permissions it has. This tool // collapses that fan-out into one call: pass a path and a list // of targets, get back per-target mount/df/ls/stat output as // JSON. All commands are read-only, so no approval is needed. {tool: &mcp.Tool{Name: "inspect_path", Description: "Bulk fact-gathering: run mount/df/ls/stat for the same path across multiple host/LXC/VM targets in ONE call. Returns a JSON object keyed by target slug, each with the target's view of the path (mount source, filesystem, size, top-level entries with ownership/permissions). Use this instead of N separate `run` calls when you need to understand a path's footprint across the fleet (e.g. tracing where a volume is mounted, checking permissions on the same NFS path from server + client). All commands are read-only — no approval needed.", InputSchema: objSchema( prop{"path", "string", "Absolute path to inspect on each target (e.g. /mnt/media_local, /media/ludo-library)."}, prop{"targets", "array", "List of target entity slugs (host:strong, lxc:nfs-export, vm:zimaos, …). Up to 8 per call."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) path, _ := args["path"].(string) if path == "" { return textResult("error: path is required"), nil } rawTargets, _ := args["targets"].([]any) if len(rawTargets) == 0 { return textResult("error: at least one target is required"), nil } if len(rawTargets) > 8 { return textResult("error: at most 8 targets per inspect_path call (use two calls if you need more)"), nil } targets := make([]string, 0, len(rawTargets)) for _, t := range rawTargets { if s, ok := t.(string); ok && s != "" { targets = append(targets, s) } } results := inspectPathAcrossTargets(ctx, pool, path, targets) out, _ := json.MarshalIndent(results, "", " ") return textResult(string(out)), nil }}, {tool: &mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.", InputSchema: objSchema( prop{"url", "string", "Absolute http(s) URL to fetch"}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) rawURL, _ := args["url"].(string) return httpGet(ctx, rawURL), nil }}, {tool: &mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution", InputSchema: objSchema( prop{"execution_id", "string", "Execution UUID (from request_execution output)"}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) execID, _ := args["execution_id"].(string) if execID == "" { return textResult("execution_id required"), nil } eid, err := uuid.Parse(execID) if err != nil { // Try finding by exec slug prefix var found uuid.UUID err2 := pool.QueryRow(ctx, "SELECT entity_id FROM executions WHERE entity_id::text LIKE $1 LIMIT 1", execID+"%").Scan(&found) if err2 != nil { return textResult(fmt.Sprintf("execution not found: %s", execID)), nil } eid = found } return queryRows(ctx, pool, ` SELECT e.entity_id::text, e.action, e.risk_class, e.status, e.result::text, e.duration_ms, e.started_at::text, e.completed_at::text, e.correlation_id FROM executions e WHERE e.entity_id = $1`, eid), nil }}, {tool: &mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity", InputSchema: objSchema( prop{"entity_id", "string", "Entity slug"}, prop{"days", "integer", "Look-back window in days (default 7)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_id"].(string) days := int(getFloat(args, "days", 7)) return queryRows(ctx, pool, ` SELECT metric, ROUND(avg(value)::numeric, 2) AS avg_val, ROUND(stddev(value)::numeric, 2) AS std_val, count(*) AS sample_count, ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope FROM metric_samples ms JOIN entities e ON e.id = ms.entity_id WHERE e.slug = $1 AND ts >= now() - make_interval(days => $2) GROUP BY metric ORDER BY metric`, slug, days), nil }}, {tool: &mcp.Tool{Name: "get_event_timeline", Description: "Get recent events", InputSchema: objSchema( prop{"severity", "string", "Filter by severity (info, warn, error)"}, prop{"entity_slug", "string", "Filter by entity slug"}, prop{"limit", "integer", "Max rows (default 50)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) return queryRows(ctx, pool, ` SELECT ev.ts, ev.type, ev.severity, ev.source, e.slug AS entity_slug, ev.data::text AS message, ev.correlation_id FROM events ev LEFT JOIN entities e ON e.id = ev.entity_id WHERE ($1::text IS NULL OR ev.severity = $1) AND ($2::text IS NULL OR e.slug = $2) ORDER BY ev.ts DESC LIMIT $3`, nStr(args["severity"]), nStr(args["entity_slug"]), limit), nil }}, {tool: &mcp.Tool{Name: "get_agent_activity", Description: "Agent self-inspection: query agent activity log", InputSchema: objSchema( prop{"limit", "integer", "Max rows (default 50)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) limit := int(getFloat(args, "limit", 50)) return annotateJSONResult(queryRows(ctx, pool, ` SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, entity_id::text, left(input_summary, 200) AS input_summary, left(output_summary, 200) AS output_summary, duration_ms, token_count, success, correlation_id FROM agent_activity WHERE agent_id = $1 ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil }}, // ─── Phase 5: operational MCP tools ────────────────────────────── {tool: &mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, state, and last-audited hint. Pass state=\"active\" to exclude destroyed/deprecated containers. The last_audited_at column shows the most recent knowledge entry (investigation or document tagged audit/update) linked via an 'about' edge — use it to skip re-running `run` against LXCs that were already audited recently.", InputSchema: objSchema( prop{"state", "string", "Optional: filter by entity state (active, destroyed, …)"}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { state, _ := argsMap(req)["state"].(string) var statePtr *string if state != "" { statePtr = &state } return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, e.attributes->>'lan_ip' AS lan_ip, e.state, st.health, st.last_check_at, (SELECT MAX(k.created_at) FROM relationships r JOIN knowledge_entities k ON k.entity_id = r.source_id WHERE r.target_id = e.id AND r.type = 'about' AND r.valid_to IS NULL AND (k.tags @> ARRAY['audit']::text[] OR k.tags @> ARRAY['update']::text[] OR k.title ILIKE '%audit%' OR k.title ILIKE '%update%') ) AS last_audited_at FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.type = 'lxc' AND ($1::text IS NULL OR e.state = $1) ORDER BY CASE WHEN e.state = 'active' THEN 0 ELSE 1 END, (e.attributes->>'pve_id')::int`, statePtr), "lxc_list"), nil }}, {tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["service_slug"].(string) if slug == "" { return textResult("service_slug is required"), nil } rows, err := pool.Query(ctx, ` SELECT st.health, st.last_check_at, e.attributes->>'url' AS url FROM entity_status st JOIN entities e ON e.id = st.entity_id WHERE e.slug = $1`, slug) if err != nil { return textResult(fmt.Sprintf("query error: %v", err)), nil } defer rows.Close() if !rows.Next() { return textResult(fmt.Sprintf("service not found: %s", slug)), nil } var health, lastCheck, url string rows.Scan(&health, &lastCheck, &url) if url == "" { url = "(no URL in entity attributes)" } return textResult(fmt.Sprintf("health=%s last_check=%s url=%s", health, lastCheck, url)), nil }}, {tool: &mcp.Tool{Name: "tail_log", Description: "Get recent log lines from a service via journalctl", InputSchema: objSchema( prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}, prop{"lines", "integer", "Number of lines (default 50)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["service_slug"].(string) n := int(getFloat(args, "lines", 50)) if slug == "" { return textResult("service_slug is required"), nil } host, user, err := resolveHost(ctx, pool, slug) if err != nil { return textResult(fmt.Sprintf("resolve host: %v", err)), nil } svc := strings.TrimPrefix(slug, "lxc:") out, err := sshExec(ctx, host, user, fmt.Sprintf("journalctl -u %s -n %d --no-pager 2>&1 || true", svc, n)) if err != nil { return textResult(fmt.Sprintf("ssh: %v", err)), nil } return textResult(out), nil }}, {tool: &mcp.Tool{Name: "get_service_status", Description: "Check systemd service status on a host", InputSchema: objSchema( prop{"service_slug", "string", "Service entity slug (e.g. lxc:caddy)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["service_slug"].(string) if slug == "" { return textResult("service_slug is required"), nil } host, user, err := resolveHost(ctx, pool, slug) if err != nil { return textResult(fmt.Sprintf("resolve host: %v", err)), nil } svc := strings.TrimPrefix(slug, "lxc:") out, err := sshExec(ctx, host, user, fmt.Sprintf("systemctl is-active %s; systemctl is-enabled %s; systemctl show %s -p ActiveEnterTimestamp -p SubState 2>&1 || true", svc, svc, svc)) if err != nil { return textResult(fmt.Sprintf("ssh: %v", err)), nil } return textResult(out), nil }}, {tool: &mcp.Tool{Name: "get_lxc_state", Description: "Get LXC container resource state from Proxmox host", InputSchema: objSchema( prop{"lxc_slug", "string", "LXC entity slug (e.g. lxc:caddy)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["lxc_slug"].(string) if slug == "" { return textResult("lxc_slug is required"), nil } var pveID string err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", slug).Scan(&pveID) if err != nil || pveID == "" { return textResult(fmt.Sprintf("LXC not found or missing pve_id: %s", slug)), nil } // Resolve the Proxmox host — find the host that runs this LXC var hostID uuid.UUID err = pool.QueryRow(ctx, ` SELECT t.id FROM entities t JOIN relationships r ON r.source_id = t.id JOIN entities s ON s.id = r.target_id WHERE s.slug = $1 AND r.type = 'hosts' AND r.valid_to IS NULL LIMIT 1`, slug).Scan(&hostID) if err != nil { // Fallback: use the inventory host attribute if no relationship var hostSlug string err = pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", slug).Scan(&hostSlug) if err != nil || hostSlug == "" { return textResult(fmt.Sprintf("cannot resolve Proxmox host for %s", slug)), nil } var host, user string host, user, err = resolveHost(ctx, pool, "host:"+hostSlug) if err != nil { return textResult(fmt.Sprintf("resolve: %v", err)), nil } out, err2 := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) if err2 != nil { return textResult(fmt.Sprintf("ssh: %v", err2)), nil } return textResult(out), nil } var hostSlug string pool.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", hostID).Scan(&hostSlug) host, user, err := resolveHost(ctx, pool, hostSlug) if err != nil { return textResult(fmt.Sprintf("resolve host: %v", err)), nil } out, err := sshExec(ctx, host, user, fmt.Sprintf("pct status %s --verbose 2>&1 || true", pveID)) if err != nil { return textResult(fmt.Sprintf("ssh: %v", err)), nil } return textResult(out), nil }}, // ─── Client introspection tools (plan: client-lifecycle Phase 3) ── {tool: &mcp.Tool{Name: "whoami", Description: "Get the current entity record, peers, and health for a host", InputSchema: objSchema(prop{"hostname", "string", "Hostname of the calling machine"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) hostname, _ := args["hostname"].(string) if hostname == "" { return textResult("error: hostname required"), nil } slug := "ws:" + hostname return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check, e.attributes->>'mesh_ip' AS mesh_ip, e.attributes->>'age_pubkey' AS age_pubkey, e.enrolled_at FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.slug = $1 ORDER BY e.slug`, slug), "entity_card"), nil }}, {tool: &mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", InputSchema: objSchema(prop{"service_slug", "string", "Service entity slug (e.g. service:jellyfin, lxc:caddy)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["service_slug"].(string) if slug == "" { return textResult("error: service_slug required"), nil } return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.name, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check, e.version, e.updated_at, COALESCE(e.attributes::text, '{}') AS attrs FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.slug = $1`, slug), "entity_card"), nil }}, {tool: &mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", InputSchema: objSchema( prop{"service_slug", "string", "Entity slug"}, prop{"action", "string", "Planned action (restart, deploy, destroy, etc.)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["service_slug"].(string) action, _ := args["action"].(string) if slug == "" || action == "" { return textResult("error: service_slug and action required"), nil } return queryRows(ctx, pool, ` SELECT e.slug, e.type, e.state, CASE WHEN $2 IN ('restart', 'logs', 'status') THEN 'reversible_low' WHEN $2 IN ('deploy', 'upgrade', 'configure') THEN 'config_mutation' WHEN $2 IN ('destroy', 'wipe', 'revoke') THEN 'destructive' ELSE 'read_only' END AS risk_class, CASE WHEN $2 IN ('read_only','reversible_low') THEN 'auto-act' WHEN $2 = 'config_mutation' THEN 'operator-approval' ELSE 'operator-approval+confirmation' END AS approval FROM entities e WHERE e.slug = $1`, slug, action), nil }}, // classify_command is the command-scoped preflight from // plans/2026-07-20-session-review-ten-sessions.md P0.2. The // existing `preflight` tool is entity/action-scoped — useless when // the agent is composing a `run` command and needs to know whether // the classifier will accept it before submitting. Without this, // the agent has to retry with cosmetic variations until it finds // one that passes (see sessions a51e2086, 8acea2e3 — three // duplicate rclone sessions, all bouncing off the classifier). // Call this BEFORE `run` whenever the classification is uncertain. {tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.", InputSchema: objSchema( prop{"command", "string", "The exact shell command you intend to pass to run."}, prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."}, ), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) command, _ := args["command"].(string) declaredRisk, _ := args["declared_risk"].(string) if command == "" { return textResult("error: command is required"), nil } risk := policy.ClassifyCommand(command, declaredRisk) note := "" switch risk { case policy.RiskReadOnly: note = "auto-acts on `run` (no approval needed)." case policy.RiskReversibleLow: note = "auto-acts on `run` (no approval needed)." case policy.RiskConfigMutation: note = "requires operator approval on `run` (or loose assent window active)." case policy.RiskDestructive: note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)." } out, _ := json.Marshal(map[string]any{ "command": command, "declared_risk": declaredRisk, "risk_class": risk, "note": note, }) return textResult(string(out)), nil }}, {tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity", InputSchema: objSchema( prop{"entity_slug", "string", "Entity slug"}, prop{"limit", "integer", "Max entries (default 20)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) slug, _ := args["entity_slug"].(string) limit := int(getFloat(args, "limit", 20)) return annotateJSONResult(queryRows(ctx, pool, ` SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, al.action, al.method, al.path, al.detail::text AS details FROM audit_log al JOIN entities e ON e.id = al.entity_id WHERE e.slug = $1 ORDER BY al.ts DESC LIMIT $2`, slug, limit), "change_log"), nil }}, {tool: &mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", InputSchema: objSchema(), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return annotateJSONResult(queryRows(ctx, pool, ` SELECT e.slug, e.type, e.state, COALESCE(st.health, 'unknown') AS health, COALESCE(st.last_check_at::text, '') AS last_check FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.state IS NOT NULL OR st.health IS NOT NULL ORDER BY st.health, e.slug LIMIT 200 `), "fleet_snapshot"), nil }}, {tool: &mcp.Tool{Name: "audit_knowledge_graph", Description: "Read-only drift report over the knowledge graph and monitoring: orphan check entities, checks targeting deprecated/destroyed entities, probes stuck down/unknown, unmonitored declared entity types, and live edges pointing at destroyed targets. Returns ranked findings with a suggested remediation runbook each. Use this to validate the graph is complete and consistent before trusting health/blast-radius answers. Does NOT mutate anything.", InputSchema: objSchema(), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { findings, summary := audit.Report(ctx, pool) b, _ := json.Marshal(map[string]any{"findings": findings, "summary": summary}) return textResult(string(b)), nil }}, {tool: &mcp.Tool{Name: "discover_infra_drift", Description: "Read-only live discovery: compares running Proxmox guests (pct/qm list on every proxmox host) against the DB graph. Returns guests running with no entity (missing) and entities whose pve_id is no longer live (ghost) — drift the DB-only audit_knowledge_graph cannot see. Reaches hosts over the same SSH/pct path the checks use. Does NOT mutate anything.", InputSchema: objSchema(), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { b, _ := json.Marshal(discoverInfraDrift(ctx, pool)) return textResult(string(b)), nil }}, {tool: &mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", InputSchema: objSchema(prop{"caller_pubkey", "string", "Age public key of the caller (optional)"}), }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) pubkey, _ := args["caller_pubkey"].(string) // Match entities where age_pubkey attribute contains the caller's key. query := ` SELECT e.slug, e.type, e.name, e.attributes->>'age_pubkey' AS age_pubkey FROM entities e WHERE e.attributes->>'age_pubkey' IS NOT NULL` var dbArgs []any if pubkey != "" { query += ` AND e.attributes->>'age_pubkey' = $1` dbArgs = append(dbArgs, pubkey) } query += ` ORDER BY e.slug LIMIT 100` return queryRows(ctx, pool, query, dbArgs...), nil }}, } } // formatCheckResult renders a human-readable summary of what check derivation // did, appended to a base status message. Shared by create_entity and // update_entity_attributes so both surface the same check-regeneration signal // (created / undeclared / skipped) to the agent. func formatCheckResult(res checkdefaults.Result) string { var b strings.Builder if res.Created > 0 { fmt.Fprintf(&b, " Derived %d check(s).", res.Created) } if res.Undeclared { b.WriteString(" Type declares no monitoring — no checks derived (set the entity's `monitoring` attribute and call update_entity_attributes to regenerate).") } for _, s := range res.Skipped { fmt.Fprintf(&b, " Skipped %s (%s).", s.Kind, s.Reason) } return b.String() } func formatCreateResult(slug, entityType string, res checkdefaults.Result) string { return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res)) }