- get_event_timeline: event_type/actor/message → type/source/data::text - get_blast_radius: add JOIN entities for slug column - get_state_snapshot: remove nonexistent disk_usage_pct, drift_count - get_change_history: timestamp/actor_label/details → ts/actor_id::text/detail - seed: upsert entity_status rows during inventory ingest (was empty, causing INNER JOIN on get_health_summary to return nothing)
7.5 KiB
2026-07-08 — Fix MCP analysis tools (SQL errors + missing probe data)
Status: Done
Goal
Fix MCP tools that fail silently or crash with SQL errors, and close the
gap that leaves all health data NULL. Hermes testing against host:strong
revealed the static read tools work but analysis/health tools are dead.
Full MCP tool test results (2026-07-08)
Tested by running each MCP tool's SQL directly against the running dev DB (docker exec oikos-postgres-1 psql). DB state: 168 entities, 194 relationships, 54 knowledge docs, 40 agent activity rows. entity_status has 0 rows, check_defs has 0.
✅ Working (SQL valid, data present or empty as expected)
| Tool | Notes |
|---|---|
| get_entity | Full attributes returned |
| list_entities | Filters work |
| get_relations | 194 edges. strong → 7 LXC hosts edges confirmed |
| search_knowledge | PostgreSQL FTS with ts_rank, returns ranked docs |
| get_entity_knowledge | Complex UNION query works, returns docs + investigations |
| list_lxcs | Returns all 33 LXCs with PVE IDs and IPs |
| get_lxc_state | SSH to Proxmox, confirmed working (agent_activity has 40 logs) |
| get_service_status | SSH-based, works |
| tail_log | SSH-based, works |
| ping_service | Works (returns unknown health, as expected) |
| get_agent_activity | 40 rows logged |
| get_audit_trail | Correct SQL, 1 audit row |
| get_signal_history | Correct SQL, 0 rows (no signals yet) |
| get_patterns | Correct SQL, 0 rows (no patterns learned yet) |
| get_skills | Correct SQL, 0 rows (no skills seeded) |
| query_metrics | Correct SQL (TimescaleDB functions work), 0 rows |
| get_trend | Correct SQL, 0 rows |
| whoami | Works (ws:mac-mini returns unknown health) |
| explain | Works for host:strong |
| preflight | Works, returns risk_class + approval |
| request_execution | SSH-based execution works |
| get_execution_status | Not tested (~likely works) |
| list_my_secrets | Not tested (~simple entities JOIN) |
❌ Broken — SQL errors on column name mismatch
| Tool | File:Line | Error |
|---|---|---|
| get_event_timeline | server.go:433 |
ev.event_type → column is type |
server.go:433 |
ev.actor → column is source |
|
server.go:434 |
ev.message → column is data (JSONB) |
|
| get_blast_radius | server.go:116 |
slug not in blast_radius() return |
| get_state_snapshot | server.go:696 |
st.disk_usage_pct doesn't exist |
server.go:697 |
st.drift_count doesn't exist |
|
| get_change_history | server.go:679 |
al.timestamp → column is ts |
server.go:679 |
al.actor_label doesn't exist |
|
server.go:681 |
al.details → column is detail |
⚠️ Returns empty (no data, SQL is correct)
| Tool | Root cause |
|---|---|
| get_health_summary | INNER JOIN entity_status — table has 0 rows |
| All health/status fields | No entity_status rows seeded, no check_defs → scheduler idle |
Fix 1 — get_event_timeline: wrong column names → SQL error
File: internal/mcp/server.go:432-440
| MCP query column | Actual events column | Migration ref |
|---|---|---|
ev.event_type |
type |
006_observability.up.sql:83 |
ev.actor |
source |
006_observability.up.sql:86 |
ev.message |
data (JSONB) |
006_observability.up.sql:87 |
Fix: Change ev.event_type → ev.type, ev.actor → ev.source,
ev.message → ev.data::text AS message.
Risk class: reversible_low — one-line string change.
Fix 2 — get_blast_radius: slug missing from function result
File: internal/mcp/server.go:115-117
SELECT slug, CAST(depth AS int)
FROM blast_radius((SELECT id FROM entities WHERE slug = $1), $2)
blast_radius() returns TABLE(entity_id UUID, depth INT) per
migrations/002_entities.up.sql:37. The slug column doesn't exist in
the function return set.
Fix: Join entities:
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
The httpapi/impl.go:285 and integration_test.go:277 already use this
pattern — copy it.
Risk class: reversible_low — JOIN added to existing query.
Fix 3 — get_state_snapshot: nonexistent columns
File: internal/mcp/server.go:692-703
The entity_status table schema (migration 003_operations.up.sql:55):
| Column | Type |
|---|---|
entity_id |
UUID PK |
health |
TEXT |
last_check_at |
TIMESTAMPTZ |
details |
JSONB |
updated_at |
TIMESTAMPTZ |
No disk_usage_pct, no drift_count.
Fix: Remove the nonexistent columns:
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
ORDER BY st.health, e.slug
LIMIT 200
Risk class: reversible_low — remove 2 columns from SELECT.
Fix 4 — get_change_history: wrong column names
File: internal/mcp/server.go:678-686
The audit_log table schema (migration 006_observability.up.sql:53):
| MCP query column | Actual column |
|---|---|
al.timestamp |
ts |
al.actor_label |
does not exist (use al.actor_id::text) |
al.details |
detail |
Fix:
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
Risk class: reversible_low — column name corrections.
Fix 5 — get_health_summary returns no rows: seed entity_status
File: internal/db/seed.go
Problem:
IngestInventorySeedcreates entities but never createsentity_statusrows.get_health_summaryusesINNER JOIN entity_status, so it returns empty.- The scheduler only populates
entity_statuswhencheck_defsexist and produce probe results. No check_defs → no status rows → empty health.
Fix: After entity creation in IngestInventorySeed, upsert a
entity_status row with health='unknown' for each entity. This gives
the fleet a visible baseline (all unknown) instead of empty results.
INSERT INTO entity_status (entity_id, health, updated_at)
VALUES ($1, 'unknown', now())
ON CONFLICT (entity_id) DO NOTHING
Risk class: config_mutation — changes seed behavior; only runs on oikos seed.
Fix 6 — Gap: no check_defs seeded → control loop idle
Not in this plan. The scheduler at internal/scheduler/scheduler.go:58
logs "no enabled check_defs" and exits every cycle. Without check_defs
there are no probe results, no signal lifecycle, and health stays
permanently unknown.
This requires:
- Define probe targets for each service in
seeds/inventory.yaml(HTTP URLs, TCP ports, disk thresholds, cert expiry) - Add
check_defsingestion tointernal/db/seed.go
Will be a separate plan — it's feature work, not a bug fix. See
seeds/inventory.yaml for the existing service URL attributes that can
be translated to check configs.
Verification
- Build and deploy to dev.
- Run
oikos seed(re-ingest). - Call each previously-broken tool via Hermes or direct SQL:
get_health_summary→ returns rows (allunknowninitially).get_event_timeline→ no SQL error (may return empty, which is fine).get_blast_radiusforhost:strong→ returns its 7 LXCs with depth.get_state_snapshot→ returns all 168 entities (all unknown health).get_change_history→ no SQL error (may return empty).
make testpasses.