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

117 lines
4.1 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
)
// agentSettingsRow is the single (id=1) agent_settings row governing the AI
// Assistant's tool-calling loop and whether the MCP endpoint is reachable at
// all. Both are admin-controlled and safe-by-default: MCP starts disabled,
// and the tool-call ceiling has a generous default rather than being a
// hardcoded Go constant that would need a code change to raise.
type agentSettingsRow struct {
mcpEnabled bool
apiEnabled bool
maxToolIterations int
}
const defaultMaxToolIterations = 8
func (s *Server) loadAgentSettings(ctx context.Context) (agentSettingsRow, error) {
var row agentSettingsRow
var mcpEnabled, apiEnabled int
err := s.db.QueryRowContext(ctx, `SELECT mcp_enabled, api_enabled, max_tool_iterations FROM agent_settings WHERE id = 1`).
Scan(&mcpEnabled, &apiEnabled, &row.maxToolIterations)
if err == sql.ErrNoRows {
return agentSettingsRow{mcpEnabled: false, apiEnabled: true, maxToolIterations: defaultMaxToolIterations}, nil
}
if err != nil {
return agentSettingsRow{}, err
}
row.mcpEnabled = mcpEnabled == 1
row.apiEnabled = apiEnabled == 1
return row, nil
}
func (s *Server) saveAgentSettings(ctx context.Context, row agentSettingsRow) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO agent_settings (id, mcp_enabled, api_enabled, max_tool_iterations, updated_at)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET mcp_enabled = excluded.mcp_enabled, api_enabled = excluded.api_enabled, max_tool_iterations = excluded.max_tool_iterations, updated_at = excluded.updated_at`,
boolToInt(row.mcpEnabled), boolToInt(row.apiEnabled), row.maxToolIterations, time.Now().UTC().Format(time.RFC3339))
return err
}
type agentSettingsResponse struct {
McpEnabled bool `json:"mcpEnabled"`
ApiEnabled bool `json:"apiEnabled"`
MaxToolIterations int `json:"maxToolIterations"`
}
// getAgentStatus is the trimmed, non-admin-gated counterpart to
// getAgentSettings — every user needs to know whether MCP/API are enabled
// before they can decide what kind of key to create, but only admins can
// see/change the full settings (including the tool-iteration ceiling).
func (s *Server) getAgentStatus(w http.ResponseWriter, r *http.Request) {
row, err := s.loadAgentSettings(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"mcpEnabled": row.mcpEnabled, "apiEnabled": row.apiEnabled})
}
func (s *Server) getAgentSettings(w http.ResponseWriter, r *http.Request) {
row, err := s.loadAgentSettings(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, agentSettingsResponse{McpEnabled: row.mcpEnabled, ApiEnabled: row.apiEnabled, MaxToolIterations: row.maxToolIterations})
}
type agentSettingsPatch struct {
McpEnabled *bool `json:"mcpEnabled"`
ApiEnabled *bool `json:"apiEnabled"`
MaxToolIterations *int `json:"maxToolIterations"`
}
func (s *Server) putAgentSettings(w http.ResponseWriter, r *http.Request) {
var patch agentSettingsPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
return
}
row, err := s.loadAgentSettings(r.Context())
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
if patch.McpEnabled != nil {
row.mcpEnabled = *patch.McpEnabled
}
if patch.ApiEnabled != nil {
row.apiEnabled = *patch.ApiEnabled
}
if patch.MaxToolIterations != nil {
if *patch.MaxToolIterations < 1 || *patch.MaxToolIterations > 50 {
writeErrorMsg(w, http.StatusBadRequest, "maxToolIterations must be between 1 and 50")
return
}
row.maxToolIterations = *patch.MaxToolIterations
}
if err := s.saveAgentSettings(r.Context(), row); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "settings.agent", "settings", "updated")
writeJSON(w, http.StatusOK, agentSettingsResponse{McpEnabled: row.mcpEnabled, ApiEnabled: row.apiEnabled, MaxToolIterations: row.maxToolIterations})
}