diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 42738d9..a5c77b2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -223,17 +223,19 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { nStr(args["status"])), nil }) - register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path)", + register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (Hermes-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade.", InputSchema: objSchema( - prop{"target", "string", "Target entity slug"}, - prop{"action", "string", "Action to perform"}, + prop{"target", "string", "Target entity slug (e.g. lxc:caddy)"}, + prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade"}, + prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade'"}, ), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) targetSlug, _ := args["target"].(string) action, _ := args["action"].(string) + params, _ := args["params"].(string) if targetSlug == "" || action == "" { - return textResult("error: target and action are required"), nil + return textResult("error: target and action required"), nil } var targetID uuid.UUID @@ -241,34 +243,97 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil } - id, err := uuid.NewV7() - if err != nil { - return textResult(fmt.Sprintf("uuid error: %v", err)), nil - } + id, _ := uuid.NewV7() correlationID := uuid.New().String() - execName := action + " on " + targetSlug + " (" + id.String()[:8] + ")" + // Write execution record execSlug := "exec:" + targetSlug + ":" + id.String()[:8] + pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`, + id, execSlug, action+" on "+targetSlug) + pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5)`, + id, targetID, action+":"+params, correlationID, agentID) - _, err = pool.Exec(ctx, ` - INSERT INTO entities (id, slug, type, name, attributes) - VALUES ($1, $2, 'execution', $3, '{}')`, - id, execSlug, execName) - if err != nil { - return textResult(fmt.Sprintf("insert entity: %v", err)), nil + // Execute reversible actions immediately + switch action { + case "restart": + host, user, err := resolveHost(ctx, pool, targetSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve: %v", err)), nil + } + svc := strings.TrimPrefix(targetSlug, "lxc:") + out, err := sshExec(ctx, host, user, fmt.Sprintf("systemctl restart %s 2>&1; sleep 1; systemctl is-active %s", svc, svc)) + result := fmt.Sprintf("restart %s: %s", svc, out) + if err != nil { + result = fmt.Sprintf("restart %s: ERROR %v", svc, err) + } + pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, + id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) + return textResult(result), nil + + case "systemctl": + host, user, err := resolveHost(ctx, pool, targetSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve: %v", err)), nil + } + svc := strings.TrimPrefix(targetSlug, "lxc:") + cmd := fmt.Sprintf("systemctl %s %s 2>&1; sleep 1; systemctl is-active %s", params, svc, svc) + if params == "enable" || params == "disable" { + // config_mutation — mark as pending for operator approval + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + return textResult(fmt.Sprintf("systemctl %s on %s requires approval — execution %s queued", params, svc, id)), nil + } + out, err := sshExec(ctx, host, user, cmd) + result := fmt.Sprintf("systemctl %s %s: %s", params, svc, out) + if err != nil { + result = fmt.Sprintf("systemctl %s %s: ERROR %v", params, svc, err) + } + pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, + id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) + return textResult(result), nil + + case "pct_exec": + var pveID string + if err := pool.QueryRow(ctx, "SELECT attributes->>'pve_id' FROM entities WHERE slug = $1", targetSlug).Scan(&pveID); err != nil || pveID == "" { + return textResult(fmt.Sprintf("LXC not found: %s", targetSlug)), nil + } + // Resolve Proxmox host + var hostSlug string + pool.QueryRow(ctx, "SELECT attributes->>'host' FROM entities WHERE slug = $1", targetSlug).Scan(&hostSlug) + if hostSlug == "" { + hostSlug = "host:hubris" // default + } + host, user, err := resolveHost(ctx, pool, hostSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve Proxmox host: %v", err)), nil + } + out, err := sshExec(ctx, host, user, fmt.Sprintf("pct exec %s -- %s 2>&1", pveID, params)) + result := fmt.Sprintf("pct exec %s: %s", pveID, out) + if err != nil { + result = fmt.Sprintf("pct exec %s: ERROR %v", pveID, err) + } + pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, + id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) + return textResult(result), nil + + case "apt_upgrade": + if params == "audit" { + host, user, err := resolveHost(ctx, pool, targetSlug) + if err != nil { + return textResult(fmt.Sprintf("resolve: %v", err)), nil + } + out, err := sshExec(ctx, host, user, "apt update -qq 2>&1 >/dev/null; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l; apt list --upgradable 2>/dev/null | tail -n +2 | head -20") + if err != nil { + return textResult(fmt.Sprintf("apt audit error: %v", err)), nil + } + return textResult("apt audit:\n" + out), nil + } + // upgrade requires approval — queue + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id) + return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil + + default: + return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade", action)), nil } - - _, err = pool.Exec(ctx, ` - INSERT INTO executions (entity_id, target_entity_id, action, risk_class, - status, correlation_id, agent_id) - VALUES ($1, $2, $3, 'unclassified', 'pending', $4, $5)`, - id, targetID, action, correlationID, agentID) - if err != nil { - return textResult(fmt.Sprintf("insert execution: %v", err)), nil - } - - return textResult(fmt.Sprintf("execution requested: id=%s target=%s action=%s correlation=%s", - id, targetSlug, action, correlationID)), nil }) register(&mcp.Tool{Name: "get_trend", Description: "Get metric trends for an entity",