mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
22c1dd382c
- 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.
117 lines
4.1 KiB
Go
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})
|
|
}
|