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.
600 lines
30 KiB
Go
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
|
|
}},
|
|
}
|
|
}
|