Files
Anand 22c1dd382c Add API keys, MCP server, admin AI providers, and a built-in local LLM option
- User-scoped API keys (Profile > API Keys) for 3rd-party REST API access
  and MCP clients, each locked to one scope at creation, with expiry,
  revocation, and last-used tracking.
- A hand-rolled MCP (Model Context Protocol) server exposing the fleet
  (connections, nodes, guests, storage, pools, alerts, cluster status) as
  read tools plus one admin-gated power-action tool, so Claude Code/Desktop
  or any other MCP client can query and operate the fleet directly.
- Both the REST API and MCP are off by default and toggleable instance-wide
  from Settings > API & MCP, enforced live on every request.
- Admin-managed AI providers (any OpenAI-chat-completions-compatible
  endpoint) backing the AI Assistant's tool-calling loop, replacing the
  single hardcoded provider.
- A built-in, zero-config, no-API-key local provider backed by Needle 2
  (internal/needle) for fully offline tool-calling, wired in as a one-click
  preset. Requires the operator to separately download the Needle 2 binary
  and point FERRUM_NEEDLE_BIN at it -- Ferrum never fetches executable
  content from the network itself; see README "Built-in LLM (Needle 2)".
- System settings (CORS allow-list, instance-wide toggles) moved to the
  admin Settings UI; environment variables are now scoped to true
  bootstrap-level config only (listen address, TLS, DB connection, secret,
  optional Needle binary path).
- Fixed: node Journal tab 502'ing with "unexpected end of JSON input" on an
  empty response, and separately with a decode error on PVE versions that
  return a bare-string journal line instead of the documented {n,t} object.
- Fixed: bottom content padding disappearing on every page except the AI
  Assistant (an unconditional h-full on the content wrapper let overflowing
  content bleed through where the padding should render).
- Fixed: Profile page felt cramped despite a wide viewport (stray max-w-2xl
  cap not present on the equivalent Settings page).
- Test coverage added for the previously-untested MCP package and the new
  Needle adapter (20 new Go tests), plus a regression test for the journal
  decode fix.
2026-09-06 13:26:30 +05:30

383 lines
13 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"ferrum/internal/auth"
"ferrum/internal/connections"
"ferrum/internal/pve"
)
type toolFunc func(ctx context.Context, s *Server, user *auth.User, args json.RawMessage) toolCallResult
var allowedPowerActions = map[string]bool{
"start": true, "stop": true, "shutdown": true, "reset": true, "suspend": true, "resume": true,
}
var toolDefinitions = []Tool{
{
Name: "list_connections",
Description: "List every Proxmox VE connection (cluster/host) Ferrum manages.",
InputSchema: schemaRequired(nil),
},
{
Name: "list_nodes",
Description: "List the nodes in a connection, with basic status (CPU, memory, uptime).",
InputSchema: schemaRequired(map[string]string{"connectionId": "The connection ID from list_connections."}, "connectionId"),
},
{
Name: "get_node_status",
Description: "Get detailed status for one node (CPU, memory, disk, load average, kernel/PVE version, uptime).",
InputSchema: schemaRequired(map[string]string{
"connectionId": "The connection ID from list_connections.",
"node": "The node name.",
}, "connectionId", "node"),
},
{
Name: "list_guests",
Description: "List every VM and container in a connection, with status, node, and resource usage.",
InputSchema: schemaRequired(map[string]string{"connectionId": "The connection ID from list_connections."}, "connectionId"),
},
{
Name: "get_guest_status",
Description: "Get detailed status for one VM/container.",
InputSchema: withIntProps(schemaRequired(map[string]string{
"connectionId": "The connection ID from list_connections.",
"node": "The node the guest lives on.",
"type": "\"qemu\" or \"lxc\".",
"vmid": "The guest's numeric VMID.",
}, "connectionId", "node", "type", "vmid"), "vmid"),
},
{
Name: "guest_power_action",
Description: "Start, stop, shut down, reset, suspend, or resume a VM/container. Requires an admin API key.",
InputSchema: withIntProps(schemaRequired(map[string]string{
"connectionId": "The connection ID from list_connections.",
"node": "The node the guest lives on.",
"type": "\"qemu\" or \"lxc\".",
"vmid": "The guest's numeric VMID.",
"action": "One of: start, stop, shutdown, reset, suspend, resume.",
}, "connectionId", "node", "type", "vmid", "action"), "vmid"),
},
{
Name: "list_alerts",
Description: "List currently active alert instances across every connection.",
InputSchema: schemaRequired(nil),
},
{
Name: "cluster_status",
Description: "Get cluster membership and quorum status for a connection (single-node setups return a minimal one-node view).",
InputSchema: schemaRequired(map[string]string{"connectionId": "The connection ID from list_connections."}, "connectionId"),
},
{
Name: "list_storage",
Description: "List storage pools in a connection, with type and usage.",
InputSchema: schemaRequired(map[string]string{"connectionId": "The connection ID from list_connections."}, "connectionId"),
},
{
Name: "list_pools",
Description: "List resource pools in a connection.",
InputSchema: schemaRequired(map[string]string{"connectionId": "The connection ID from list_connections."}, "connectionId"),
},
}
func schemaRequired(props map[string]string, required ...string) map[string]any {
p := map[string]any{}
for name, desc := range props {
p[name] = map[string]string{"type": "string", "description": desc}
}
s := map[string]any{"type": "object", "properties": p}
if len(required) > 0 {
s["required"] = required
}
return s
}
// withIntProps re-types the given property names from "string" to "integer"
// on an already-built schema — used for fields like vmid that are numeric in
// the Go struct (json.Unmarshal rejects a JSON string into an int field), so
// a spec-following caller sends the right JSON type instead of one that
// fails to unmarshal.
func withIntProps(schema map[string]any, names ...string) map[string]any {
props, _ := schema["properties"].(map[string]any)
for _, name := range names {
if prop, ok := props[name].(map[string]string); ok {
props[name] = map[string]string{"type": "integer", "description": prop["description"]}
}
}
return schema
}
var toolHandlers = map[string]toolFunc{
"list_connections": toolListConnections,
"list_nodes": toolListNodes,
"get_node_status": toolGetNodeStatus,
"list_guests": toolListGuests,
"get_guest_status": toolGetGuestStatus,
"guest_power_action": toolGuestPowerAction,
"list_alerts": toolListAlerts,
"cluster_status": toolClusterStatus,
"list_storage": toolListStorage,
"list_pools": toolListPools,
}
func toolClusterStatus(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a connectionIDArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" {
return errorResult("connectionId is required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
status, err := client.ClusterStatus(ctx)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(status)
}
func toolListStorage(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a connectionIDArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" {
return errorResult("connectionId is required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(filterResources(resources, "storage"))
}
func toolListPools(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a connectionIDArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" {
return errorResult("connectionId is required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(filterResources(resources, "pool"))
}
func toolListConnections(ctx context.Context, s *Server, _ *auth.User, _ json.RawMessage) toolCallResult {
list, err := s.resolver.List(ctx)
if err != nil {
return errorResult(err.Error())
}
if list == nil {
list = []connections.Info{}
}
return jsonResult(list)
}
type connectionIDArgs struct {
ConnectionID string `json:"connectionId"`
}
func filterResources(resources []pve.ClusterResource, types ...string) []pve.ClusterResource {
want := map[string]bool{}
for _, t := range types {
want[t] = true
}
out := make([]pve.ClusterResource, 0, len(resources))
for _, res := range resources {
if want[res.Type] {
out = append(out, res)
}
}
return out
}
func toolListNodes(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a connectionIDArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" {
return errorResult("connectionId is required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(filterResources(resources, "node"))
}
func toolListGuests(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a connectionIDArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" {
return errorResult("connectionId is required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(filterResources(resources, "qemu", "lxc"))
}
type nodeArgs struct {
ConnectionID string `json:"connectionId"`
Node string `json:"node"`
}
func toolGetNodeStatus(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a nodeArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" || a.Node == "" {
return errorResult("connectionId and node are required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
status, err := client.NodeStatus(ctx, a.Node)
if err != nil {
return errorResult(err.Error())
}
return jsonResult(status)
}
type guestArgs struct {
ConnectionID string `json:"connectionId"`
Node string `json:"node"`
Type string `json:"type"`
VMID int `json:"vmid"`
}
func toolGetGuestStatus(ctx context.Context, s *Server, _ *auth.User, args json.RawMessage) toolCallResult {
var a guestArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" || a.Node == "" || a.Type == "" || a.VMID == 0 {
return errorResult("connectionId, node, type, and vmid are required")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
resources, err := client.ClusterResources(ctx)
if err != nil {
return errorResult(err.Error())
}
for _, res := range resources {
if res.Type == a.Type && res.Node == a.Node && res.VMID == a.VMID {
return jsonResult(res)
}
}
return errorResult("guest not found")
}
type powerActionArgs struct {
ConnectionID string `json:"connectionId"`
Node string `json:"node"`
Type string `json:"type"`
VMID int `json:"vmid"`
Action string `json:"action"`
}
// toolGuestPowerAction is the one mutating tool exposed over MCP, so it
// enforces the same admin-only rule requireAdminForMutations applies to the
// equivalent REST endpoint — an API key minted by a non-admin user can read
// the fleet through MCP but not act on it.
func toolGuestPowerAction(ctx context.Context, s *Server, user *auth.User, args json.RawMessage) toolCallResult {
if user == nil || !user.IsAdmin {
return errorResult("admin access required for this operation")
}
var a powerActionArgs
if err := json.Unmarshal(args, &a); err != nil || a.ConnectionID == "" || a.Node == "" || a.Type == "" || a.VMID == 0 || a.Action == "" {
return errorResult("connectionId, node, type, vmid, and action are required")
}
if !allowedPowerActions[a.Action] {
return errorResult("action must be one of: start, stop, shutdown, reset, suspend, resume")
}
client, err := s.resolver.ClientFor(ctx, a.ConnectionID)
if err != nil {
return errorResult(err.Error())
}
upid, err := client.GuestPowerAction(ctx, a.Type, a.Node, a.VMID, a.Action)
if err != nil {
return errorResult(err.Error())
}
if s.audit != nil {
s.audit(ctx, user.ID, "vm."+a.Action, "vm", fmt.Sprintf("%s/%s/%d (via MCP)", a.Node, a.Type, a.VMID))
}
return textResult(fmt.Sprintf("action %q submitted for %s/%s/%d — task %s", a.Action, a.Node, a.Type, a.VMID, upid))
}
type mcpAlert struct {
ID string `json:"id"`
ConnectionName string `json:"connectionName"`
ResourceName string `json:"resourceName"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Threshold float64 `json:"threshold"`
Severity string `json:"severity"`
TriggeredAt string `json:"triggeredAt"`
}
func toolListAlerts(ctx context.Context, s *Server, _ *auth.User, _ json.RawMessage) toolCallResult {
rows, err := s.db.QueryContext(ctx, `
SELECT id, connection_name, resource_name, metric, value, threshold, severity, triggered_at
FROM alert_instances WHERE status = 'active' ORDER BY triggered_at DESC LIMIT 200`)
if err != nil {
return errorResult(err.Error())
}
defer rows.Close()
out := []mcpAlert{}
for rows.Next() {
var a mcpAlert
if err := rows.Scan(&a.ID, &a.ConnectionName, &a.ResourceName, &a.Metric, &a.Value, &a.Threshold, &a.Severity, &a.TriggeredAt); err != nil {
return errorResult(err.Error())
}
out = append(out, a)
}
return jsonResult(out)
}
// ToolDefinitions exposes the tool catalog (name, description, JSON schema)
// for callers that need to build their own tool listing — the AI
// Assistant's function-calling loop (internal/api/ai_chat.go) advertises
// these same tools to the LLM instead of maintaining a second definition.
func ToolDefinitions() []Tool {
return toolDefinitions
}
// CallTool executes one tool by name and flattens the result to plain text
// plus an error flag — used by the AI Assistant's function-calling loop
// (internal/api/ai_chat.go), so there is exactly one implementation of what
// each tool does and who's allowed to call it, shared with the MCP JSON-RPC
// dispatcher (Handle). source identifies the caller ("chat") for the
// activity trail — see RecordFunc.
func (s *Server) CallTool(ctx context.Context, user *auth.User, source, name string, args json.RawMessage) (text string, isError bool) {
result := s.callTool(ctx, user, source, name, args)
var sb strings.Builder
for _, c := range result.Content {
sb.WriteString(c.Text)
}
return sb.String(), result.IsError
}
func jsonResult(v any) toolCallResult {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return errorResult(err.Error())
}
return textResult(string(b))
}