0.22.0 — full MCP agent surface: 19 new tools, async run, session reliability
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Stage 1 — Foundation:
- Target validation: iptables + systemctl/docker target-type gates
- Async run for long-running commands (sleep/wait/poll loops)
- audit_log.session_id plumbing (SQL, sqlcgen, 18 call sites)

Stage 2 — External agent observe (11 tools):
- get_dashboard_summary, get_ontology, list_checks, list_executions
- get_knowledge_revisions, get_knowledge_duplicates, get_knowledge_orphans
- list_knowledge_tags, list_entity_sessions, find_entities_by
- 3 resource templates: oikos://entity/{slug}, knowledge/{id}, execution/{id}

Stage 3 — Nomos reliability:
- complete_task(success) refused without verification (upgraded from warn)
- sessionHasPlan excludes replaced steps (forces propose_plan after reopen)
- Bash syntax validation in run() (rejects literal \n, flag-space typos)
- Scope gate in SOUL.md (ask before pivoting to unrelated subsystem)

Stage 4 — External agent act (9 mutation tools):
- ack_signal, resolve_signal, mute_signal, cancel_execution
- update_check, delete_knowledge, restore_knowledge
- merge_knowledge, rename_knowledge_tag
This commit is contained in:
2026-08-04 23:51:55 +02:00
parent 85f0bb67fa
commit 4e294b3630
20 changed files with 1264 additions and 41 deletions

View File

@@ -5,10 +5,13 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/dtoro/oikos/internal/audit"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/policy"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -96,7 +99,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
}, 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
SELECT id, ts, actor_type, action, entity_id::text, method, path, correlation_id, session_id::text
FROM audit_log
WHERE ($1::text IS NULL OR entity_id::text = $1)
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
@@ -966,7 +969,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
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.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
@@ -1024,6 +1027,546 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
query += ` ORDER BY e.slug LIMIT 100`
return queryRows(ctx, pool, query, dbArgs...), nil
}},
// ── Stage 2: External agent observe ──────────────────────────
// ── Stage 4: External agent act (mutations) ─────────────────────
{tool: &mcp.Tool{Name: "ack_signal", Description: "Acknowledge an open signal. Use when investigating an alert — marks it as seen and being worked on.",
InputSchema: objSchema(prop{"signal_id", "string", "Signal entity UUID"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
sid, _ := args["signal_id"].(string)
id, err := uuid.Parse(sid)
if err != nil {
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
}
tag, err := pool.Exec(ctx,
`UPDATE signals SET state = 'acknowledged', updated_at = now()
WHERE entity_id = $1 AND state IN ('raised','failed')`, id)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be acknowledged", sid)), nil
}
return textResult(fmt.Sprintf("Signal %s acknowledged.", sid)), nil
}},
{tool: &mcp.Tool{Name: "resolve_signal", Description: "Resolve a signal with an optional resolution note. Use when the underlying issue is fixed — marks the signal as resolved so it stops showing as active.",
InputSchema: objSchema(
prop{"signal_id", "string", "Signal entity UUID"},
prop{"resolution", "string", "Optional note describing what fixed it"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
sid, _ := args["signal_id"].(string)
id, err := uuid.Parse(sid)
if err != nil {
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
}
tag, err := pool.Exec(ctx,
`UPDATE signals SET state = 'resolved', updated_at = now()
WHERE entity_id = $1 AND state IN ('raised','acknowledged','acting','failed')`, id)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be resolved", sid)), nil
}
resolution, _ := args["resolution"].(string)
if resolution != "" {
return textResult(fmt.Sprintf("Signal %s resolved: %s", sid, resolution)), nil
}
return textResult(fmt.Sprintf("Signal %s resolved.", sid)), nil
}},
{tool: &mcp.Tool{Name: "mute_signal", Description: "Temporarily mute a signal. Suppresses it from active views for the given duration. Use for known, non-urgent issues that don't need immediate attention.",
InputSchema: objSchema(
prop{"signal_id", "string", "Signal entity UUID"},
prop{"duration_s", "integer", "Mute duration in seconds (default 3600 = 1 hour)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
sid, _ := args["signal_id"].(string)
id, err := uuid.Parse(sid)
if err != nil {
return textResult(fmt.Sprintf("invalid signal_id: %v", err)), nil
}
dur := int64(getFloat(args, "duration_s", 3600))
muteUntil := time.Now().UTC().Add(time.Duration(dur) * time.Second)
tag, err := pool.Exec(ctx,
`UPDATE signals SET state = 'muted', mute_until = $2, updated_at = now()
WHERE entity_id = $1 AND state IN ('raised','acknowledged')`, id, muteUntil)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult(fmt.Sprintf("signal %s not found or not in a state that can be muted", sid)), nil
}
return textResult(fmt.Sprintf("Signal %s muted until %s.", sid, muteUntil.Format(time.RFC3339))), nil
}},
{tool: &mcp.Tool{Name: "cancel_execution", Description: "Cancel a queued or running execution. Use when you realize the command was wrong, targets the wrong host, or should not proceed. Requires a reason.",
InputSchema: objSchema(
prop{"execution_id", "string", "Execution entity UUID"},
prop{"reason", "string", "Why this execution should be cancelled"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
eid, _ := args["execution_id"].(string)
id, err := uuid.Parse(eid)
if err != nil {
return textResult(fmt.Sprintf("invalid execution_id: %v", err)), nil
}
reason, _ := args["reason"].(string)
result := jsonErr("cancelled by agent: %s", reason)
tag, err := pool.Exec(ctx,
`UPDATE executions SET status = 'cancelled', result = $2::jsonb
WHERE entity_id = $1 AND status IN ('running','pending_approval','approved','queued')`,
id, result)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult(fmt.Sprintf("execution %s not found or already final", eid)), nil
}
// Write audit entry.
_ = observability.Audit(ctx, sqlcgen.New(pool), "agent", "nomos", "cancel",
&id, "POST", "/mcp", "", nil,
map[string]any{"reason": reason})
return textResult(fmt.Sprintf("Execution %s cancelled: %s", eid, reason)), nil
}},
{tool: &mcp.Tool{Name: "update_check", Description: "Enable or disable a health check. Disable a noisy probe that's firing false positives; re-enable after fixing the underlying issue.",
InputSchema: objSchema(
prop{"check_id", "string", "Check entity UUID"},
prop{"enabled", "boolean", "true to enable, false to disable"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
cid, _ := args["check_id"].(string)
id, err := uuid.Parse(cid)
if err != nil {
return textResult(fmt.Sprintf("invalid check_id: %v", err)), nil
}
enabled, _ := args["enabled"].(bool)
tag, err := pool.Exec(ctx,
`UPDATE check_defs SET enabled = $2 WHERE entity_id = $1`, id, enabled)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult(fmt.Sprintf("check %s not found", cid)), nil
}
status := "enabled"
if !enabled {
status = "disabled"
}
return textResult(fmt.Sprintf("Check %s %s.", cid, status)), nil
}},
{tool: &mcp.Tool{Name: "delete_knowledge", Description: "Soft-delete a knowledge entry (move to trash, restorable with restore_knowledge). The content and revision history survive.",
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["knowledge_slug"].(string)
var entityID uuid.UUID
if u, err := uuid.Parse(slug); err == nil {
entityID = u
} else {
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
}
if entityID == uuid.Nil {
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
}
// Snapshot before tombstoning.
pool.Exec(ctx, `
INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at)
SELECT entity_id, title, content, source, tags, COALESCE(edited_by,''), updated_at
FROM knowledge_entities WHERE entity_id = $1`, entityID)
tag, err := pool.Exec(ctx,
`UPDATE knowledge_entities SET deleted_at = now(), edited_by = 'nomos'
WHERE entity_id = $1 AND deleted_at IS NULL`, entityID)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult("knowledge entry already deleted"), nil
}
return textResult(fmt.Sprintf("Knowledge %s soft-deleted. Restore with restore_knowledge.", slug)), nil
}},
{tool: &mcp.Tool{Name: "restore_knowledge", Description: "Restore a soft-deleted knowledge entry from trash. Undoes delete_knowledge.",
InputSchema: objSchema(prop{"knowledge_slug", "string", "Knowledge entity slug or UUID"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["knowledge_slug"].(string)
var entityID uuid.UUID
if u, err := uuid.Parse(slug); err == nil {
entityID = u
} else {
pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&entityID)
}
if entityID == uuid.Nil {
return textResult(fmt.Sprintf("knowledge entry not found: %s", slug)), nil
}
tag, err := pool.Exec(ctx,
`UPDATE knowledge_entities SET deleted_at = NULL, edited_by = 'nomos'
WHERE entity_id = $1 AND deleted_at IS NOT NULL`, entityID)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if tag.RowsAffected() == 0 {
return textResult("knowledge entry is not deleted"), nil
}
return textResult(fmt.Sprintf("Knowledge %s restored from trash.", slug)), nil
}},
{tool: &mcp.Tool{Name: "merge_knowledge", Description: "Fold one or more knowledge entries into a target. Source content is appended under a provenance heading, and the union of all tags is kept. Sources are soft-deleted afterwards.",
InputSchema: objSchema(
prop{"target_slug", "string", "Knowledge entry to merge INTO (slug or UUID)"},
prop{"source_slugs", "string", "Comma-separated slugs of entries to fold into the target"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
targetSlug, _ := args["target_slug"].(string)
sourceStr, _ := args["source_slugs"].(string)
var targetID uuid.UUID
if u, err := uuid.Parse(targetSlug); err == nil {
targetID = u
} else {
pool.QueryRow(ctx, `
SELECT ke.entity_id FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
targetSlug).Scan(&targetID)
}
if targetID == uuid.Nil {
return textResult(fmt.Sprintf("target knowledge entry not found: %s", targetSlug)), nil
}
sources := []string{}
for _, s := range strings.Split(sourceStr, ",") {
if s = strings.TrimSpace(s); s != "" && s != targetSlug {
sources = append(sources, s)
}
}
if len(sources) == 0 {
return textResult("no valid source entries to merge"), nil
}
var appended strings.Builder
merged := []string{}
for _, srcSlug := range sources {
var title, content, updated string
var tags []string
err := pool.QueryRow(ctx, `
SELECT ke.title, ke.content, COALESCE(ke.tags,'{}'), ke.updated_at::text
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE (e.slug = $1 OR e.id::text = $1) AND ke.deleted_at IS NULL`,
srcSlug).Scan(&title, &content, &tags, &updated)
if err != nil {
continue
}
appended.WriteString("\n\n---\n\n## Merged: ")
appended.WriteString(title)
appended.WriteString("\n\n*Originally ")
appended.WriteString(srcSlug)
appended.WriteString(", last updated ")
appended.WriteString(updated)
appended.WriteString("*\n\n")
appended.WriteString(content)
for _, t := range tags {
fmt.Fprintf(&appended, "\ntag: %s", strings.ToLower(strings.TrimSpace(t)))
}
merged = append(merged, srcSlug)
}
if len(merged) == 0 {
return textResult("no source entries could be read"), nil
}
_, err := pool.Exec(ctx, `
UPDATE knowledge_entities SET content = content || $2, edited_by = 'nomos', updated_at = now()
WHERE entity_id = $1`, targetID, appended.String())
if err != nil {
return textResult(fmt.Sprintf("error appending content: %v", err)), nil
}
for _, srcSlug := range merged {
pool.Exec(ctx, `
UPDATE knowledge_entities ke SET deleted_at = now(), edited_by = 'nomos'
FROM entities e
WHERE e.id = ke.entity_id AND (e.slug = $1 OR e.id::text = $1)`,
srcSlug)
}
return textResult(fmt.Sprintf("Merged %d entries into %s: %s", len(merged), targetSlug, strings.Join(merged, ", "))), nil
}},
{tool: &mcp.Tool{Name: "rename_knowledge_tag", Description: "Bulk-rename one or more tags across all knowledge entries. Case-insensitive matching — 'oom' and 'OOM' are treated as the same tag. Deduplicates after rename.",
InputSchema: objSchema(
prop{"from", "string", "Comma-separated tag names to rename FROM"},
prop{"to", "string", "New tag name"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
fromStr, _ := args["from"].(string)
to, _ := args["to"].(string)
to = strings.ToLower(strings.TrimSpace(to))
from := []string{}
for _, f := range strings.Split(fromStr, ",") {
if f = strings.TrimSpace(f); f != "" {
from = append(from, strings.ToLower(f))
}
}
if to == "" || len(from) == 0 {
return textResult("from and to are required"), nil
}
tag, err := pool.Exec(ctx, `
UPDATE knowledge_entities ke
SET tags = sub.new_tags, updated_at = now()
FROM (
SELECT k.entity_id,
ARRAY(SELECT DISTINCT CASE WHEN lower(t) = ANY($1) THEN $2 ELSE t END
FROM unnest(k.tags) AS t) AS new_tags
FROM knowledge_entities k
WHERE k.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM unnest(k.tags) AS t WHERE lower(t) = ANY($1))
) AS sub
WHERE ke.entity_id = sub.entity_id`, from, to)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
return textResult(fmt.Sprintf("Tag %s → %s: %d entries updated.", strings.Join(from, ", "), to, tag.RowsAffected())), nil
}},
// ── Stage 2: External agent observe ──────────────────────────
{tool: &mcp.Tool{Name: "get_dashboard_summary", Description: "Fleet overview in one call: entity counts by type and state, health breakdown (healthy/degraded/down/stale/unknown), active signals by severity, pending approval count, execution counts in last 24h, and event rate over last 6h.",
InputSchema: objSchema(),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
result := map[string]any{}
// Entity counts by type
result["entities_by_type"] = rowsToMap(ctx, pool,
`SELECT type, count(*) FROM entities GROUP BY type`)
// Entity counts by state
result["entities_by_state"] = rowsToMap(ctx, pool,
`SELECT coalesce(state, 'unknown'), count(*) FROM entities GROUP BY state`)
// Health rollup (excluding check entities)
result["health"] = rowsToMap(ctx, pool, `
SELECT COALESCE(st.health, 'unknown') AS health, count(*)
FROM entity_status st JOIN entities e ON e.id = st.entity_id
WHERE e.type <> 'check' GROUP BY st.health`)
// Active signals by severity
result["signals_by_severity"] = rowsToMap(ctx, pool, `
SELECT severity, count(*) FROM signals
WHERE state NOT IN ('resolved', 'failed') GROUP BY severity`)
// Pending approvals
var pending int
pool.QueryRow(ctx, `SELECT count(*) FROM approvals WHERE status = 'pending'`).Scan(&pending)
result["approvals_pending"] = pending
// Executions in last 24h
result["executions_by_state"] = rowsToMap(ctx, pool, `
SELECT status, count(*) FROM executions
WHERE created_at > now() - interval '24 hours' GROUP BY status`)
// Event rate (5-min buckets over 6h)
events := []map[string]any{}
erows, _ := pool.Query(ctx, `
SELECT date_trunc('hour', ts) + (extract(minute FROM ts)::int / 5) * interval '5 minutes' AS bucket, count(*)
FROM events WHERE ts > now() - interval '6 hours'
GROUP BY bucket ORDER BY bucket`)
if erows != nil {
for erows.Next() {
var bucket time.Time
var n int
if erows.Scan(&bucket, &n) == nil {
events = append(events, map[string]any{"bucket": bucket, "count": n})
}
}
erows.Close()
}
result["event_rate"] = events
b, _ := json.MarshalIndent(result, "", " ")
return textResult(string(b)), nil
}},
{tool: &mcp.Tool{Name: "get_ontology", Description: "Entity types, relationship types, and lifecycle definitions. Use this to understand the schema — what entity types exist, what relationships connect them, and what lifecycle states each type supports.",
InputSchema: objSchema(),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
etResult := queryRowsJSONSingle(ctx, pool, `
SELECT name, parent_type, is_abstract, domain, layer,
description, lifecycle_id, schema_version, status
FROM entity_types ORDER BY name`)
rtResult := queryRowsJSONSingle(ctx, pool, `
SELECT name, inverse, source_type, target_type,
cardinality, description
FROM relationship_types ORDER BY name`)
lcResult := queryRowsJSONSingle(ctx, pool, `
SELECT id, name, states, transitions::text
FROM lifecycles ORDER BY name`)
result := map[string]any{
"entity_types": etResult,
"relationship_types": rtResult,
"lifecycles": lcResult,
}
b, _ := json.MarshalIndent(result, "", " ")
return textResult(string(b)), nil
}},
{tool: &mcp.Tool{Name: "list_checks", Description: "List health checks with verdict, last run time, probe kind, and config. Filter by entity slug or enabled status. Each check's last_health explains which probe is responsible for an entity's overall health.",
InputSchema: objSchema(
prop{"entity_slug", "string", "Filter by target entity slug"},
prop{"enabled", "boolean", "Filter enabled/disabled (optional)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
return annotateJSONResult(queryRows(ctx, pool, `
SELECT cd.entity_id, e.slug, cd.kind,
COALESCE(te.slug, '') AS target_slug, cd.target_type,
cd.config::text, cd.interval_s, cd.timeout_s, cd.enabled,
e.version, cd.last_health, cd.last_run_at::text
FROM check_defs cd
JOIN entities e ON e.id = cd.entity_id
LEFT JOIN entities te ON te.id = cd.target_id
WHERE ($1::text IS NULL OR te.slug = $1)
AND ($2::bool IS NULL OR cd.enabled = $2)
ORDER BY e.slug LIMIT 200`,
nStr(args["entity_slug"]), args["enabled"]), "check_table"), nil
}},
{tool: &mcp.Tool{Name: "list_executions", Description: "Cursor-paginated execution history. Filter by entity slug, status, or risk class. Returns newest-first with duration, result, and target info.",
InputSchema: objSchema(
prop{"entity_slug", "string", "Filter by target entity slug"},
prop{"status", "string", "Filter by status (running/completed/failed/pending_approval)"},
prop{"limit", "integer", "Max rows (default 25)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
limit := int(getFloat(args, "limit", 25))
return queryRows(ctx, pool, `
SELECT e.entity_id, te.slug AS target, e.action, e.risk_class,
e.status, e.result::text, e.duration_ms,
e.correlation_id, e.started_at::text, e.completed_at::text, e.created_at::text,
COALESCE(npe.session_id::text, '') AS session_id
FROM executions e
JOIN entities te ON te.id = e.target_entity_id
LEFT JOIN nomos_plan_executions npe ON npe.execution_id = e.entity_id
WHERE ($1::text IS NULL OR te.slug = $1)
AND ($2::text IS NULL OR e.status = $2)
ORDER BY e.created_at DESC LIMIT $3`,
nStr(args["entity_slug"]), nStr(args["status"]), limit), nil
}},
{tool: &mcp.Tool{Name: "get_knowledge_revisions", Description: "Version history for a knowledge entry. Returns title, content, editor, tags, and timestamps for each revision.",
InputSchema: objSchema(
prop{"knowledge_slug", "string", "Knowledge entity slug (e.g. document:nomos/something)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["knowledge_slug"].(string)
return queryRows(ctx, pool, `
SELECT kr.id, kr.title, kr.content, COALESCE(kr.edited_by, '') AS edited_by,
COALESCE(kr.tags::text, '{}') AS tags,
kr.version_at::text, kr.revised_at::text
FROM knowledge_revisions kr
JOIN entities e ON e.id = kr.entity_id
WHERE e.slug = $1
ORDER BY kr.version_at DESC LIMIT 50`, slug), nil
}},
{tool: &mcp.Tool{Name: "get_knowledge_duplicates", Description: "Near-duplicate knowledge entries detected via trigram similarity. Returns clusters of similar documents with similarity scores. Use before creating new knowledge to avoid pileup.",
InputSchema: objSchema(
prop{"threshold", "number", "Similarity threshold 0-1 (default 0.6, lower = more matches)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
threshold := getFloat(args, "threshold", 0.6)
return queryRows(ctx, pool, `
SELECT a.slug AS doc_a, b.slug AS doc_b, similarity(ka.title, kb.title) AS sim
FROM knowledge_entities ka
JOIN knowledge_entities kb ON ka.entity_id < kb.entity_id
JOIN entities a ON a.id = ka.entity_id
JOIN entities b ON b.id = kb.entity_id
WHERE ka.deleted_at IS NULL AND kb.deleted_at IS NULL
AND similarity(ka.title, kb.title) > $1
ORDER BY sim DESC LIMIT 100`, threshold), nil
}},
{tool: &mcp.Tool{Name: "get_knowledge_orphans", Description: "Knowledge entries with no entity links (unlinked), no tags (untagged), or stale (not updated in N days). Helps identify abandoned or disconnected knowledge to clean up.",
InputSchema: objSchema(
prop{"stale_days", "integer", "Days without update to consider stale (default 90)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
staleDays := int(getFloat(args, "stale_days", 90))
return queryRows(ctx, pool, fmt.Sprintf(`
SELECT e.slug, ke.title, e.type AS kind, COALESCE(ke.edited_by, '') AS edited_by,
ke.updated_at::text,
(ke.tags IS NULL OR cardinality(ke.tags) = 0) AS untagged,
NOT EXISTS (
SELECT 1 FROM relationships r
WHERE r.source_id = ke.entity_id AND r.valid_to IS NULL
AND r.type IN ('documents', 'about')
) AS unlinked,
(ke.updated_at < now() - interval '%d days') AS stale
FROM knowledge_entities ke
JOIN entities e ON e.id = ke.entity_id
WHERE ke.deleted_at IS NULL
ORDER BY ke.updated_at ASC`, staleDays)), nil
}},
{tool: &mcp.Tool{Name: "list_knowledge_tags", Description: "All tags used across the knowledge base with usage counts. Returns normalized tag, count, and any casing variants (e.g. 'oom' and 'OOM' surface as variants so you can spot drift).",
InputSchema: objSchema(),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, `
SELECT lower(tag) AS tag, count(*) AS uses,
array_agg(DISTINCT tag ORDER BY tag) AS variants
FROM knowledge_entities ke, unnest(ke.tags) AS tag
WHERE ke.deleted_at IS NULL
GROUP BY lower(tag) ORDER BY uses DESC, lower(tag)`), nil
}},
{tool: &mcp.Tool{Name: "list_entity_sessions", Description: "Active Nomos sessions (tasks) linked to an entity. Shows goal, status, outcome, and when the session was last active. Use to discover what agents are working on related to this entity.",
InputSchema: objSchema(
prop{"entity_slug", "string", "Entity slug to find sessions for"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["entity_slug"].(string)
return queryRows(ctx, pool, `
SELECT DISTINCT as2.id, as2.title, as2.goal, as2.status, as2.outcome,
as2.summary, as2.last_active_at::text, as2.closed_at::text
FROM agent_sessions as2
JOIN nomos_plan_executions npe ON npe.session_id = as2.id
JOIN executions ex ON ex.entity_id = npe.execution_id
JOIN entities te ON te.id = ex.target_entity_id
WHERE te.slug = $1 AND as2.closed_at IS NULL
ORDER BY as2.last_active_at DESC LIMIT 20`, slug), 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
}},
}
}
@@ -1048,3 +1591,47 @@ func formatCheckResult(res checkdefaults.Result) string {
func formatCreateResult(slug, entityType string, res checkdefaults.Result) string {
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
}
// rowsToMap runs a SELECT key, value query and returns the result as a
// map[string]any. Used by get_dashboard_summary to aggregate count queries.
func rowsToMap(ctx context.Context, pool *db.Pool, query string, args ...any) map[string]any {
m := map[string]any{}
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return m
}
defer rows.Close()
for rows.Next() {
var key string
var val int
if rows.Scan(&key, &val) == nil {
m[key] = val
}
}
return m
}
// queryRowsJSONSingle runs a query and returns the rows as a parsed JSON array
// of maps. Used by get_ontology to embed sub-queries into a structured result.
func queryRowsJSONSingle(ctx context.Context, pool *db.Pool, query string, args ...any) []map[string]any {
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil
}
defer rows.Close()
cols := rows.FieldDescriptions()
var items []map[string]any
for rows.Next() {
vals, err := rows.Values()
if err != nil {
continue
}
m := make(map[string]any)
for i, col := range cols {
m[string(col.Name)] = fmt.Sprintf("%v", vals[i])
}
items = append(items, m)
}
return items
}