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.
171 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|