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

171 lines
5.7 KiB
Go

package mcp
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"ferrum/internal/auth"
"ferrum/internal/config"
"ferrum/internal/connections"
"ferrum/internal/secrets"
"ferrum/internal/store"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "ferrum.db")
db, err := store.Open(config.DBConfig{Driver: "sqlite", Path: dbPath})
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
box, err := secrets.New("test-secret-that-is-long-enough")
if err != nil {
t.Fatalf("secrets.New: %v", err)
}
resolver := connections.New(db, box)
return New(resolver, db, nil, nil)
}
func TestHandleInvalidJSON(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte("not json"))
if resp.Error == nil || resp.Error.Code != -32700 {
t.Fatalf("expected parse error, got %+v", resp)
}
}
func TestHandleWrongJSONRPCVersion(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte(`{"jsonrpc":"1.0","id":1,"method":"ping"}`))
if resp.Error == nil || resp.Error.Code != -32600 {
t.Fatalf("expected invalid request error, got %+v", resp)
}
}
func TestHandleUnknownMethod(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"nope"}`))
if resp.Error == nil || resp.Error.Code != -32601 {
t.Fatalf("expected method not found, got %+v", resp)
}
}
func TestHandleInitialize(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`))
if resp.Error != nil {
t.Fatalf("unexpected error: %+v", resp.Error)
}
res, ok := resp.Result.(initializeResult)
if !ok {
t.Fatalf("expected initializeResult, got %T", resp.Result)
}
if res.ProtocolVersion != ProtocolVersion {
t.Fatalf("protocol version = %q, want %q", res.ProtocolVersion, ProtocolVersion)
}
}
func TestHandleNotificationHasNoResponseBody(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
if resp.ID != nil || resp.Result != nil || resp.Error != nil {
t.Fatalf("expected empty notification response, got %+v", resp)
}
}
func TestHandleToolsList(t *testing.T) {
s := newTestServer(t)
resp := s.Handle(context.Background(), nil, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
if resp.Error != nil {
t.Fatalf("unexpected error: %+v", resp.Error)
}
res, ok := resp.Result.(toolsListResult)
if !ok {
t.Fatalf("expected toolsListResult, got %T", resp.Result)
}
if len(res.Tools) != len(toolDefinitions) {
t.Fatalf("got %d tools, want %d", len(res.Tools), len(toolDefinitions))
}
// Every tool advertised must actually have a handler wired up, or a
// client would see it in tools/list and then get "unknown tool" calling it.
for _, tool := range res.Tools {
if _, ok := toolHandlers[tool.Name]; !ok {
t.Errorf("tool %q listed but has no handler", tool.Name)
}
}
}
func TestListConnectionsEmpty(t *testing.T) {
s := newTestServer(t)
params, _ := json.Marshal(toolCallParams{Name: "list_connections"})
resp := s.Handle(context.Background(), &auth.User{ID: "u1"}, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":`+string(params)+`}`))
if resp.Error != nil {
t.Fatalf("unexpected error: %+v", resp.Error)
}
result, ok := resp.Result.(toolCallResult)
if !ok {
t.Fatalf("expected toolCallResult, got %T", resp.Result)
}
if result.IsError {
t.Fatalf("unexpected tool error: %+v", result)
}
}
func TestUnknownToolCall(t *testing.T) {
s := newTestServer(t)
params, _ := json.Marshal(toolCallParams{Name: "does_not_exist"})
resp := s.Handle(context.Background(), &auth.User{ID: "u1"}, []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":`+string(params)+`}`))
result, ok := resp.Result.(toolCallResult)
if !ok || !result.IsError {
t.Fatalf("expected an error tool result, got %+v", resp)
}
}
// toolGuestPowerAction is the one mutating, state-changing tool exposed over
// MCP — it must refuse a non-admin caller before ever touching a connection,
// the same rule the equivalent REST endpoint enforces (requireAdminForMutations).
func TestGuestPowerActionRequiresAdmin(t *testing.T) {
s := newTestServer(t)
args, _ := json.Marshal(powerActionArgs{ConnectionID: "c1", Node: "n1", Type: "qemu", VMID: 100, Action: "start"})
cases := []struct {
name string
user *auth.User
}{
{"nil user", nil},
{"non-admin user", &auth.User{ID: "u1", IsAdmin: false}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := toolGuestPowerAction(context.Background(), s, tc.user, args)
if !result.IsError {
t.Fatalf("expected admin-required error, got %+v", result)
}
})
}
}
func TestGuestPowerActionRejectsUnknownAction(t *testing.T) {
s := newTestServer(t)
args, _ := json.Marshal(powerActionArgs{ConnectionID: "c1", Node: "n1", Type: "qemu", VMID: 100, Action: "explode"})
result := toolGuestPowerAction(context.Background(), s, &auth.User{ID: "admin", IsAdmin: true}, args)
if !result.IsError {
t.Fatalf("expected invalid-action error, got %+v", result)
}
}
func TestGuestPowerActionRejectsMissingConnection(t *testing.T) {
s := newTestServer(t)
// A well-formed, admin-authorized request against a connection ID that
// doesn't exist must fail cleanly through resolver.ClientFor, not panic.
args, _ := json.Marshal(powerActionArgs{ConnectionID: "does-not-exist", Node: "n1", Type: "qemu", VMID: 100, Action: "start"})
result := toolGuestPowerAction(context.Background(), s, &auth.User{ID: "admin", IsAdmin: true}, args)
if !result.IsError {
t.Fatalf("expected an error for an unknown connection, got %+v", result)
}
}