Problem: entity mutations (create/update/state) existed as three drifted
copies — HTTP CreateEntity/PatchEntity, MCP create_entity/
update_entity_attributes/set_entity_state — each with its own inline
SQL, its own validation subset (MCP validated lifecycle states, HTTP
did not; HTTP patched attributes without regenerating derived checks,
MCP did; MCP wrote no audit trail), the exact drift ADR 0016's first
vertical slice exists to collapse.
Change:
- internal/core/ports: DerivedCheck, Idempotency (adapter-owned request
hash + cached-body renderer so the replay record commits in the
create's transaction), IdempotentResponse + GetIdempotent read,
AuditEntry gains Method/Path/CorrelationID, Event gains
CorrelationID; EntityUpdateInput carries ExpectedVersion +
RederiveChecks (derivation for updates runs repo-side: the graph
host fallback reads relationships through the open tx).
- internal/adapters/postgres/repositories.go: EntityRepo (Create/
Update/SetState/reads/idempotency) preserving the load-bearing
check-then-act invariants in-tx: version WHERE-clause, declared
transitions + preconditions (ValidateTransition), duplicate-slug
mapping, audit/event/writeCheck all inside one BEGIN…COMMIT.
OntologyRepo: TTL-cached OntologyStore.
- internal/core/app/entities.go: EntityService — ontology validation
(type exists, concrete, state declared — the stricter MCP rule now
governs both surfaces), default-state resolution, id generation,
derivation for creates, audit/event construction, idempotency
pass-through.
- httpapi CreateEntity/PatchEntity rewired to the service; PATCH now
regenerates derived checks (the A2 parity gap). MCP create/update/
set-state tools call the same service — and now write audit + event
rows like the HTTP surface always did.
- Integration-test seed paths fixed for the adapters/postgres package
depth (../../../seeds).
Pre-existing failures documented: TestAPIEndToEnd (entity_types 60 vs
59; 501-endpoint now 200), TestClientLifecycleEndToEnd, TestPhase3*
rows — verified failing identically at ec11956 (scratch approval-
notifier commit test drift), unrelated to this change. All mutation
integration tests (create/patch/idempotency/audit/regeneration) pass.
Verification: make test-db (postgres + mcp green, httpapi green except
the pre-existing set), full non-DB suite (19 pkgs), golangci on new
packages — 0 issues.
446 lines
24 KiB
Go
446 lines
24 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/internal/audit"
|
|
"github.com/dtoro/oikos/internal/adapters/postgres"
|
|
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/core/app"
|
|
"github.com/dtoro/oikos/internal/core/domain"
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
"github.com/google/uuid"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
func EntityTools(pool *db.Pool, agentID uuid.UUID, sec ports.Secrets, entities *app.EntityService) []toolReg {
|
|
return []toolReg{
|
|
{tool: &mcp.Tool{Name: "ping", Description: "Lightweight connectivity check. Returns server identity, no DB hit.",
|
|
InputSchema: objSchema(),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return textResult(`{"ok":true,"server":"oikos","version":"dev"}`), nil
|
|
}},
|
|
{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 := slugArg(args, "slug_or_id", "slug", "id")
|
|
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: "List inbound/outbound edges for one entity, optionally filtered by relationship type",
|
|
InputSchema: objSchema(
|
|
prop{"entity_id", "string", "Entity slug"},
|
|
prop{"types", "string", "Comma-separated relationship types to include (e.g. 'hosts,provides,depends-on'). Omit for all."},
|
|
),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
slug := slugArg(args, "entity_id", "slug")
|
|
typesStr, _ := args["types"].(string)
|
|
if slug == "" {
|
|
return textResult("entity_id is required"), nil
|
|
}
|
|
query := `
|
|
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`
|
|
if typesStr != "" {
|
|
query += ` AND r.type = ANY(string_to_array($2, ','))`
|
|
return queryRows(ctx, pool, query, slug, typesStr), nil
|
|
}
|
|
query += ` ORDER BY r.type`
|
|
return queryRows(ctx, pool, query, 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 := slugArg(args, "entity_id", "slug")
|
|
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: "create_entity", Description: "Create a new entity in the knowledge graph. Use it when a task needs an entity that does not exist yet: a service, a host/LXC/VM, an ingress, a cert, etc. After inserting, it derives default checks from the entity type's monitoring spec, so creating a checkable entity wires its monitoring in one call. Does NOT require approval. If the slug already exists it returns 'already exists' — then use update_entity_attributes to change it. FOOTGUN: creating a type=check entity creates a bare entity row but does NOT wire a check_def — the scheduler will never probe it. To add monitoring, set `monitoring: [\"http\"]` + `url` on the target via update_entity_attributes.",
|
|
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 <type>:<name>."},
|
|
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 <type>:<name>)"), 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
|
|
}
|
|
}
|
|
stateStr, _ := args["state"].(string)
|
|
|
|
// One service path with the HTTP surface (ADR 0016 Phase 3):
|
|
// ontology validation, lifecycle-state guardrails, check
|
|
// derivation, and audit/event recording converge here.
|
|
_, res, err := entities.Create(ctx, app.CreateEntityCmd{
|
|
Slug: slug,
|
|
Type: entityType,
|
|
Name: name,
|
|
State: stateStr,
|
|
Attributes: attrs,
|
|
ActorType: "agent",
|
|
Actor: "mcp",
|
|
Method: "TOOL",
|
|
Path: "create_entity",
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrAlreadyExists) {
|
|
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
|
|
}
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
|
|
}
|
|
if errors.Is(err, domain.ErrAbstractType) {
|
|
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
|
|
}
|
|
if errors.Is(err, domain.ErrInvalidTransition) {
|
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
|
}
|
|
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), 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
|
|
}
|
|
|
|
// Strip scheduler-owned keys: health is computed by the scheduler
|
|
// from probe results (spotted live 2026-08-05: an agent set
|
|
// health:"healthy" on lxc:nfs-export, which derived 4 spurious checks).
|
|
// Agents can observe health via get_health_summary / list_checks.
|
|
var blocked []string
|
|
for _, key := range []string{"health", "last_check_at", "last_check"} {
|
|
if _, ok := attrs[key]; ok {
|
|
delete(attrs, key)
|
|
blocked = append(blocked, key)
|
|
}
|
|
}
|
|
if len(blocked) > 0 {
|
|
// Re-marshal the filtered attrs
|
|
filtered, _ := json.Marshal(attrs)
|
|
attrsStr = string(filtered)
|
|
if len(attrs) == 0 {
|
|
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
|
}
|
|
}
|
|
// One service path with the HTTP surface (ADR 0016 Phase 3): merge +
|
|
// check regeneration + audit/event in one transaction.
|
|
_, res, err := entities.Update(ctx, app.UpdateEntityCmd{
|
|
SlugOrID: slug,
|
|
Attributes: attrs,
|
|
AttrsReplace: false,
|
|
RederiveChecks: true,
|
|
ActorType: "agent",
|
|
Actor: "mcp",
|
|
Method: "TOOL",
|
|
Path: "update_entity_attributes",
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrNotFound) {
|
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
|
}
|
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), 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)
|
|
|
|
ent, err := sqlcgen.New(tx).GetEntityBySlug(ctx, slug)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
|
}
|
|
id := ent.ID
|
|
entityType := ent.Type
|
|
currentState := ""
|
|
if ent.State != nil {
|
|
currentState = *ent.State
|
|
}
|
|
if err := db.ValidateTransition(ctx, tx, id, entityType, currentState, targetState); err != nil {
|
|
return textResult(fmt.Sprintf("error: %v", err)), nil
|
|
}
|
|
ra, err := sqlcgen.New(tx).SetEntityState(ctx, sqlcgen.SetEntityStateParams{ID: id, State: &targetState})
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
|
}
|
|
if ra == 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
|
|
}
|
|
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
|
}
|
|
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
|
}
|
|
_, err = sqlcgen.New(pool).InsertRelationshipIfAbsent(ctx, sqlcgen.InsertRelationshipIfAbsentParams{
|
|
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType, Attributes: []byte(`{"by":"nomos"}`),
|
|
})
|
|
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
|
|
}
|
|
srcEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, source)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
|
}
|
|
tgtEnt, err := sqlcgen.New(pool).GetEntityBySlug(ctx, target)
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
|
}
|
|
ra, err := sqlcgen.New(pool).EndCurrentRelationship(ctx, sqlcgen.EndCurrentRelationshipParams{
|
|
SourceID: srcEnt.ID, TargetID: tgtEnt.ID, Type: relType,
|
|
})
|
|
if err != nil {
|
|
return textResult(fmt.Sprintf("error ending relationship: %v", err)), nil
|
|
}
|
|
if ra == 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
|
|
}},
|
|
// ─── 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
|
|
}},
|
|
{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, al.session_id::text AS session_id
|
|
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: "find_entities_by", Description: "Search entities by discovered attributes — IP address, port, version string, tag, or any key in the attributes JSONB blob. More flexible than list_entities (which filters by type/state only). Use for reverse lookups: 'what runs on port 8096?' or 'which entities have version 2.4?'",
|
|
InputSchema: objSchema(
|
|
prop{"key", "string", "Attribute key to search (e.g. ip, port, version, tag)"},
|
|
prop{"value", "string", "Value to match (case-insensitive substring)"},
|
|
prop{"limit", "integer", "Max results (default 25)"}),
|
|
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := argsMap(req)
|
|
key, _ := args["key"].(string)
|
|
val, _ := args["value"].(string)
|
|
limit := int(getFloat(args, "limit", 25))
|
|
return queryRows(ctx, pool, `
|
|
SELECT e.slug, e.type, e.name, e.state, e.attributes->>$1 AS matched_value
|
|
FROM entities e
|
|
WHERE e.attributes ? $1
|
|
AND e.attributes->>$1 ILIKE '%'||$2||'%'
|
|
ORDER BY e.slug
|
|
LIMIT $3`, key, val, limit), nil
|
|
}},
|
|
}
|
|
}
|