Files
oikos/internal/mcp/ops_tools.go
dtoro 75c0848a6f
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
0.29.0 — code-quality refactor (plan E1–E5): file splits, sqlc migration, SSH unification, test coverage
E1: split monolithic files — cmd/nomos (main.go → server.go + mcp.go + workers.go),
    internal/mcp/tools.go → entity_tools/ops_tools/knowledge_tools/analysis_tools,
    internal/httpapi/impl.go → domain files (entities, events, signals, ontology,
    fleet_health, client_context, client_lifecycle, entity_mutations, query_audit).
E2: migrate raw pool.Exec queries to sqlc (entities/relationships queries + generated).
E3: unify SSH — consolidate crypto/ssh dial into actuator/client.go (+client_test).
E4/E5: add tests — db/lifecycle, checkdefaults/build, ontology/preconditions, policy/risk.
2026-08-08 22:47:06 +02:00

600 lines
30 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/observability"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func OpsTools(pool *db.Pool, agentID uuid.UUID, sec secretBackend) []toolReg {
return []toolReg{
// ── 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.\n\nHost-level mutations (apt-get install, dpkg, systemctl enable) always classify as config_mutation — operator approval required.\n\nVM targets: the QEMU guest agent must be running inside the VM. If the entity's qemu_guest_agent attribute is not_running, the run is blocked immediately with a clear error.",
InputSchema: objSchema(
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (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: "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: "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
}},
{tool: &mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP — returns scheduler health state plus a live HTTP probe",
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,
COALESCE(
e.attributes->>'url',
CASE WHEN e.attributes->>'public_host' IS NOT NULL
THEN 'https://' || e.attributes->>'public_host'
END
) 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 == "" {
return textResult(fmt.Sprintf("health=%s last_check=%s url=no-url (entity has no url or public_host attribute)", health, lastCheck)), nil
}
// Live HTTP probe — HEAD request to check current state
code := "n/a"
if resp, err := http.Head(url); err == nil {
resp.Body.Close()
code = fmt.Sprintf("%d", resp.StatusCode)
} else {
code = fmt.Sprintf("err: %v", err)
}
return textResult(fmt.Sprintf("health=%s last_check=%s url=%s http=%s", health, lastCheck, url, code)), 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
}},
// ── 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: "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: "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
}},
// ── 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_secret", Description: "Retrieve a secret value from the Infisical vault. Returns the secret value. Use for service credentials, tokens, and keys needed to operate the homelab.",
InputSchema: objSchema(
prop{"key", "string", "Secret key to retrieve (e.g. 'matrix-token', 'clients/host:hubris/age-key')"},
prop{"path", "string", "Secret path prefix (default '/')"},
prop{"environment", "string", "Environment slug (default 'dev')"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if sec == nil {
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
}
args := argsMap(req)
key, _ := args["key"].(string)
if key == "" {
return textResult("error: key is required"), nil
}
val, err := sec.Get(ctx, key)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
return textResult(val), nil
}},
{tool: &mcp.Tool{Name: "list_secrets", Description: "List secret keys in the Infisical vault. Returns key names only (no values). Filter by path prefix to scope to a client or shared path.",
InputSchema: objSchema(
prop{"path_prefix", "string", "Filter to keys matching this prefix (e.g. 'clients/', 'shared/', 'config/')"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if sec == nil {
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
}
args := argsMap(req)
prefix, _ := args["path_prefix"].(string)
keys, err := sec.List(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
if prefix != "" {
filtered := keys[:0]
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
filtered = append(filtered, k)
}
}
keys = filtered
}
data, _ := json.MarshalIndent(keys, "", " ")
return textResult(string(data)), nil
}},
{tool: &mcp.Tool{Name: "set_secret", Description: "Store or update a secret in the Infisical vault. Use when discovering new credentials that need to be persisted. Requires operator approval (config_mutation).",
InputSchema: objSchema(
prop{"key", "string", "Secret key to store"},
prop{"value", "string", "Secret value to store"},
prop{"path", "string", "Secret path prefix (default '/')"},
prop{"environment", "string", "Environment slug (default 'dev')"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if sec == nil {
return textResult("error: no secrets backend configured (set OIKOS_INFISICAL_SITE_URL)"), nil
}
args := argsMap(req)
key, _ := args["key"].(string)
value, _ := args["value"].(string)
if key == "" {
return textResult("error: key is required"), nil
}
if value == "" {
return textResult("error: value is required"), nil
}
if err := sec.Set(ctx, key, value); err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
return textResult(fmt.Sprintf("secret %s stored", key)), nil
}},
}
}