# 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` ```sql 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`: ```sql 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: ```sql 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:** ```sql 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: - `IngestInventorySeed` creates entities but never creates `entity_status` rows. - `get_health_summary` uses `INNER JOIN entity_status`, so it returns empty. - The scheduler only populates `entity_status` when `check_defs` exist 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. ```sql 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_defs` ingestion to `internal/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 1. Build and deploy to dev. 2. Run `oikos seed` (re-ingest). 3. Call each previously-broken tool via Hermes or direct SQL: - `get_health_summary` → returns rows (all `unknown` initially). - `get_event_timeline` → no SQL error (may return empty, which is fine). - `get_blast_radius` for `host: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). 4. `make test` passes.