feat: add mcp read tools for fleet, health and workflow data

This commit is contained in:
2026-09-08 14:12:44 +00:00
parent 98233b620c
commit 14b947f791
4 changed files with 950 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// serverSummary is what a list returns: enough for a model to decide which
// server to ask about next, and nothing else. The full document is an order of
// magnitude larger and listing thirty of them would dominate a context window.
type serverSummary struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Online bool `json:"online"`
Tags map[string]string `json:"tags,omitempty"`
}
// models.Server has no Online bool: it stores Status as one of "pending",
// "online" or "offline" (see internal/services/servers.go). Online here
// mirrors that string the same way the REST layer treats it.
func summariseServer(s models.Server) serverSummary {
return serverSummary{
ID: s.ServerID,
Hostname: s.Hostname,
OS: s.OSInfo,
Online: s.Status == "online",
Tags: s.Tags,
}
}
// defaultLimit and maxLimit bound every listing. A model asking for everything
// gets a page and is told the total, which is more useful than a truncated blob
// it cannot tell is truncated.
const (
defaultLimit = 50
maxLimit = 200
)
func pageLimit(args map[string]any) int {
n, ok := args["limit"].(float64)
if !ok || int(n) <= 0 {
return defaultLimit
}
if int(n) > maxLimit {
return maxLimit
}
return int(n)
}
func stringArg(args map[string]any, key string) string {
s, _ := args[key].(string)
return s
}
func tagArg(args map[string]any) map[string]string {
raw, ok := args["tags"].(map[string]any)
if !ok {
return nil
}
out := map[string]string{}
for k, v := range raw {
if s, ok := v.(string); ok {
out[k] = s
}
}
return out
}
type listServersResult struct {
Servers []serverSummary `json:"servers"`
Total int `json:"total"`
Shown int `json:"shown"`
}
func init() {
All().Register(Tool{
Name: "list_servers",
Scope: "servers:read",
Description: "List the servers in this Vantage fleet, optionally filtered by tags. " +
"Returns a compact summary per server; use get_server for full detail on one.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
// The requested tags and the token's restriction can never both
// hold, so the honest answer is an empty fleet.
return listServersResult{Servers: []serverSummary{}}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
limit := pageLimit(args)
out := make([]serverSummary, 0, limit)
for _, s := range servers {
if len(out) == limit {
break
}
out = append(out, summariseServer(s))
}
return listServersResult{Servers: out, Total: len(servers), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server",
Scope: "servers:read",
Description: "Get detail for one server by ID: OS, online state and tags. " +
"Use list_pending_updates for that server's outstanding package updates.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "server_id")
if id == "" {
return nil, fmt.Errorf("server_id is required")
}
srv, err := services.GetServerScoped(c.InstanceID, id, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", id)
}
return summariseServer(*srv), nil
},
})
}
+240
View File
@@ -0,0 +1,240 @@
package mcp
import (
"context"
"fmt"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// monitorSummary carries state and identity. A model asking "what is broken"
// needs the state and the name; the target URL, expected status, keyword,
// runner and channel list are configuration it did not ask for.
type monitorSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Interval int `json:"interval_sec"`
}
// models.Monitor.State is a MonitorState struct whose status field is
// Status (a plain string: models.StatusUp/StatusDown/StatusPending), not the
// ".Status" field-of-a-field the brief guessed at.
func summariseMonitor(m models.Monitor) monitorSummary {
return monitorSummary{
ID: m.MonitorID,
Name: m.Name,
Group: m.Group,
Type: m.Type,
Enabled: m.Enabled,
State: m.State.Status,
Interval: m.IntervalSec,
}
}
type listMonitorsResult struct {
Monitors []monitorSummary `json:"monitors"`
Total int `json:"total"`
Shown int `json:"shown"`
Down int `json:"down"`
}
// monitorStatusDetail is get_monitor_status's projection: enough to tell a
// model what a monitor is currently doing, without its target configuration.
type monitorStatusDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
State string `json:"state"`
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type incidentSummary struct {
ID string `json:"id"`
MonitorName string `json:"monitor_name"`
StartedAt time.Time `json:"started_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
Cause string `json:"cause,omitempty"`
}
type listIncidentsResult struct {
Incidents []incidentSummary `json:"incidents"`
Shown int `json:"shown"`
}
type monitorSample struct {
At time.Time `json:"at"`
Ok bool `json:"ok"`
LatencyMs int `json:"latency_ms"`
}
type listSamplesResult struct {
Samples []monitorSample `json:"samples"`
Shown int `json:"shown"`
}
const defaultSampleLimit = 100
const maxSampleLimit = 500
func init() {
All().Register(Tool{
Name: "list_monitors",
Scope: "monitors:read",
Description: "List the monitors on this instance with their current state. " +
"Pass state:\"down\" to see only what is currently failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
wantState := stringArg(args, "state")
limit := pageLimit(args)
out := make([]monitorSummary, 0, limit)
down, total := 0, 0
for _, m := range monitors {
summary := summariseMonitor(m)
if summary.State == "down" {
down++
}
if wantState != "" && summary.State != wantState {
continue
}
total++
if len(out) < limit {
out = append(out, summary)
}
}
return listMonitorsResult{Monitors: out, Total: total, Shown: len(out), Down: down}, nil
},
})
All().Register(Tool{
Name: "get_monitor_status",
Scope: "monitors:read",
Description: "Get one monitor's current state: up, down or pending, the last check " +
"time, and the last error message if it is failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
m, err := services.GetMonitor(c.InstanceID, id)
if err != nil || m == nil {
return nil, fmt.Errorf("no monitor %q found", id)
}
return monitorStatusDetail{
ID: m.MonitorID,
Name: m.Name,
Type: m.Type,
State: m.State.Status,
LastCheckAt: m.State.LastCheckAt,
LastError: m.State.Message,
}, nil
},
})
All().Register(Tool{
Name: "list_incidents",
Scope: "monitors:read",
Description: "List monitor incidents (outages), most recent first. Pass monitor_id to " +
"scope to one monitor, or omit it to see incidents across every monitor.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
monitorID := stringArg(args, "monitor_id")
var monitorNames map[string]string
var monitorIDs []string
if monitorID != "" {
monitorIDs = []string{monitorID}
} else {
monitors, err := services.ListMonitors(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list monitors: %w", err)
}
monitorNames = make(map[string]string, len(monitors))
for _, m := range monitors {
monitorNames[m.MonitorID] = m.Name
monitorIDs = append(monitorIDs, m.MonitorID)
}
}
out := []incidentSummary{}
for _, mid := range monitorIDs {
if len(out) >= int(limit) {
break
}
incidents, err := services.ListIncidents(c.InstanceID, mid, limit)
if err != nil {
return nil, fmt.Errorf("could not list incidents: %w", err)
}
name := mid
if monitorNames != nil {
if n, ok := monitorNames[mid]; ok {
name = n
}
} else {
if m, err := services.GetMonitor(c.InstanceID, mid); err == nil && m != nil {
name = m.Name
}
}
for _, inc := range incidents {
if len(out) >= int(limit) {
break
}
out = append(out, incidentSummary{
ID: inc.IncidentID,
MonitorName: name,
StartedAt: inc.StartedAt,
ResolvedAt: inc.ResolvedAt,
Cause: inc.Cause,
})
}
}
return listIncidentsResult{Incidents: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_monitor_samples",
Scope: "monitors:read",
Description: "Get one monitor's recent raw check results (timestamp, ok/fail, latency). " +
"Samples are numerous and expire after 48 hours; use list_incidents for a longer view.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "monitor_id")
if id == "" {
return nil, fmt.Errorf("monitor_id is required")
}
limit := int(pageLimit(args))
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
} else {
limit = defaultSampleLimit
}
if limit > maxSampleLimit {
limit = maxSampleLimit
}
samples, err := services.MonitorSamples(c.InstanceID, id, time.Now().Add(-services.MonitorSampleTTL))
if err != nil {
return nil, fmt.Errorf("could not get samples: %w", err)
}
out := make([]monitorSample, 0, limit)
for _, s := range samples {
if len(out) == limit {
break
}
out = append(out, monitorSample{At: s.At, Ok: s.Up, LatencyMs: s.LatencyMs})
}
return listSamplesResult{Samples: out, Shown: len(out)}, nil
},
})
}
+60
View File
@@ -0,0 +1,60 @@
package mcp
import (
"encoding/json"
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func TestReadToolsAreRegistered(t *testing.T) {
want := []string{
"list_servers", "get_server", "search_fleet",
"list_monitors", "get_monitor_status", "list_incidents", "get_monitor_samples",
"list_pending_updates", "list_vulnerabilities", "get_server_packages",
"list_workflows", "get_workflow", "get_run", "get_run_logs",
"list_audit_events", "list_secret_names",
}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if tool.Write {
t.Errorf("tool %q is marked as a write", name)
}
}
}
// Secret plaintext must never be reachable, at any scope. This is the one
// deliberate refusal in the read set and it is worth a test of its own.
func TestNoSecretRevealTool(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Name == "reveal_secret" || tool.Name == "get_secret" {
t.Errorf("tool %q exposes secret plaintext to a model", tool.Name)
}
}
}
// A fleet listing that costs thousands of tokens degrades every interaction
// and is otherwise invisible until someone reads a bill.
func TestServerSummaryStaysSmall(t *testing.T) {
fleet := make([]serverSummary, 30)
for i := range fleet {
fleet[i] = summariseServer(models.Server{
ServerID: "srv-000000000000000000000000",
Hostname: "web-server-with-a-longish-name",
OSInfo: "Ubuntu 24.04.1 LTS",
Tags: map[string]string{"env": "prod", "team": "core"},
})
}
out, err := json.Marshal(fleet)
if err != nil {
t.Fatal(err)
}
if len(out) > 8000 {
t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out))
}
}
+523
View File
@@ -0,0 +1,523 @@
package mcp
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// ---- workflows ----
type workflowSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Steps int `json:"steps"`
Targets int `json:"targets"`
Scheduled bool `json:"scheduled"`
}
type listWorkflowsResult struct {
Workflows []workflowSummary `json:"workflows"`
Total int `json:"total"`
Shown int `json:"shown"`
}
type workflowStepRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type workflowDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []workflowStepRef `json:"steps"`
Targets []string `json:"target_server_ids,omitempty"`
Tags map[string]string `json:"target_tags,omitempty"`
Schedule string `json:"schedule,omitempty"`
}
// ---- runs ----
type runStatusCounts struct {
Pending int `json:"pending,omitempty"`
Running int `json:"running,omitempty"`
Success int `json:"success,omitempty"`
Failed int `json:"failed,omitempty"`
Skipped int `json:"skipped,omitempty"`
}
type runDetail struct {
ID string `json:"id"`
WorkflowName string `json:"workflow_name"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ServerCounts runStatusCounts `json:"server_status_counts"`
}
type runLogsResult struct {
Lines []string `json:"lines"`
Shown int `json:"shown"`
}
const defaultLogLimit = 200
// ---- pending updates ----
type pendingUpdate struct {
ServerID string `json:"server_id"`
Hostname string `json:"hostname"`
Package string `json:"package"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type listPendingUpdatesResult struct {
Updates []pendingUpdate `json:"updates"`
Shown int `json:"shown"`
}
// ---- vulnerabilities ----
type vulnSummary struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Package string `json:"package"`
AffectedNum int `json:"affected_servers"`
FixedIn string `json:"fixed_in,omitempty"`
}
type listVulnsResult struct {
Vulnerabilities []vulnSummary `json:"vulnerabilities"`
Shown int `json:"shown"`
}
// ---- packages ----
type packageEntry struct {
Name string `json:"name"`
Version string `json:"version"`
}
type serverPackagesResult struct {
ServerID string `json:"server_id"`
Packages []packageEntry `json:"packages"`
Total int `json:"total"`
Shown int `json:"shown"`
}
// ---- search_fleet ----
type packageMatch struct {
Hostname string `json:"hostname"`
Package string `json:"package"`
Version string `json:"version"`
}
type searchFleetResult struct {
Matches []packageMatch `json:"matches"`
Shown int `json:"shown"`
}
// ---- audit ----
type auditEventSummary struct {
At time.Time `json:"at"`
Type string `json:"type"`
Actor string `json:"actor"`
Detail string `json:"detail,omitempty"`
}
type listAuditResult struct {
Events []auditEventSummary `json:"events"`
Total int `json:"shown"`
}
// ---- secrets ----
type secretGroupNames struct {
Group string `json:"group"`
Keys []string `json:"keys"`
}
type listSecretNamesResult struct {
Groups []secretGroupNames `json:"groups"`
}
func init() {
All().Register(Tool{
Name: "list_workflows",
Scope: "workflows:read",
Description: "List the workflows defined on this instance: step count, target count, " +
"and whether each is on a schedule. Use get_workflow for the ordered step list.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflows, err := services.ListWorkflows(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list workflows: %w", err)
}
limit := pageLimit(args)
out := make([]workflowSummary, 0, limit)
for _, w := range workflows {
if len(out) == limit {
break
}
targets := len(w.TargetServerIDs)
if len(w.TargetTags) > 0 {
targets = len(w.TargetTags)
}
out = append(out, workflowSummary{
ID: w.WorkflowID,
Name: w.Name,
Steps: len(w.Steps),
Targets: targets,
Scheduled: w.Schedule != nil && w.Schedule.Enabled,
})
}
return listWorkflowsResult{Workflows: out, Total: len(workflows), Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_workflow",
Scope: "workflows:read",
Description: "Get one workflow's full definition: ordered steps, targets and schedule. " +
"Use get_run for what happened the last time it ran.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "workflow_id")
if id == "" {
return nil, fmt.Errorf("workflow_id is required")
}
w, err := services.GetWorkflow(c.InstanceID, id)
if err != nil || w == nil {
return nil, fmt.Errorf("no workflow %q found", id)
}
steps := make([]workflowStepRef, 0, len(w.Steps))
for _, s := range w.Steps {
name := s.StepID
if s.Inline != nil {
name = s.Inline.Name
}
steps = append(steps, workflowStepRef{ID: s.StepID, Name: name})
}
schedule := ""
if w.Schedule != nil && w.Schedule.Enabled {
schedule = w.Schedule.Cron
}
return workflowDetail{
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: w.TargetServerIDs,
Tags: w.TargetTags,
Schedule: schedule,
}, nil
},
})
All().Register(Tool{
Name: "get_run",
Scope: "workflows:read",
Description: "Get one workflow run's status: overall state, start/finish time, and a " +
"count of servers by their per-server status. Use get_run_logs for the output of one server.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
id := stringArg(args, "run_id")
if id == "" {
return nil, fmt.Errorf("run_id is required")
}
r, err := services.GetRun(c.InstanceID, id)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", id)
}
var counts runStatusCounts
for _, sr := range r.ServerRuns {
switch sr.Status {
case "pending":
counts.Pending++
case "running":
counts.Running++
case "success":
counts.Success++
case "failed":
counts.Failed++
case "skipped":
counts.Skipped++
}
}
return runDetail{
ID: r.RunID,
WorkflowName: r.Name,
Status: r.Status,
StartedAt: r.StartedAt,
FinishedAt: r.FinishedAt,
ServerCounts: counts,
}, nil
},
})
All().Register(Tool{
Name: "get_run_logs",
Scope: "workflows:read",
Description: "Get the ordered log lines for one server within one workflow run. " +
"Capped at 200 lines by default; ask for a higher limit if you need more.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
serverID := stringArg(args, "server_id")
if runID == "" || serverID == "" {
return nil, fmt.Errorf("run_id and server_id are required")
}
// The run's own instance must be checked before any line is
// returned: ReadServerRunLog takes no instance ID and will read
// any run on the process, so GetRun is what proves this run
// belongs to the caller.
r, err := services.GetRun(c.InstanceID, runID)
if err != nil || r == nil {
return nil, fmt.Errorf("no run %q found", runID)
}
found := false
for _, sr := range r.ServerRuns {
if sr.ServerID == serverID {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
limit := defaultLogLimit
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
}
if limit > maxLimit {
limit = maxLimit
}
lines, _, err := services.ReadServerRunLog(runID, serverID, 0, limit)
if err != nil {
return nil, fmt.Errorf("could not read run log: %w", err)
}
return runLogsResult{Lines: lines, Shown: len(lines)}, nil
},
})
All().Register(Tool{
Name: "list_pending_updates",
Scope: "servers:read",
Description: "List outstanding package updates across the fleet, or for one server. " +
"Pass server_id for one server, or tags to filter by, respecting the token's own scope.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := pageLimit(args)
out := []pendingUpdate{}
serverID := stringArg(args, "server_id")
if serverID != "" {
srv, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
break
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
})
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
sel, ok := services.IntersectSelectors(c.TokenScope, tagArg(args))
if !ok {
return listPendingUpdatesResult{Updates: out}, nil
}
servers, err := services.ListServersFiltered(c.InstanceID, sel)
if err != nil {
return nil, fmt.Errorf("could not list servers: %w", err)
}
for _, srv := range servers {
for _, u := range srv.AvailableUpdates {
if len(out) == limit {
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
}
out = append(out, pendingUpdate{
ServerID: srv.ServerID, Hostname: srv.Hostname,
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
})
}
}
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_vulnerabilities",
Scope: "vulns:read",
Description: "List known CVEs affecting this fleet, one row per CVE/package pair with " +
"how many servers are affected. Filter by severity or status (open/accepted).",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
f := services.FindingFilter{
Severity: stringArg(args, "severity"),
State: stringArg(args, "status"),
}
findings, err := services.ListInstanceFindings(c.InstanceID, f)
if err != nil {
return nil, fmt.Errorf("could not list vulnerabilities: %w", err)
}
type key struct{ cve, pkg string }
counts := map[key]int{}
meta := map[key]vulnSummary{}
for _, fnd := range findings {
k := key{fnd.CVEID, fnd.PackageName}
counts[k]++
if _, seen := meta[k]; !seen {
meta[k] = vulnSummary{CVEID: fnd.CVEID, Severity: fnd.Severity, Package: fnd.PackageName, FixedIn: fnd.FixedIn}
}
}
limit := pageLimit(args)
out := make([]vulnSummary, 0, limit)
for k, v := range meta {
if len(out) == limit {
break
}
v.AffectedNum = counts[k]
out = append(out, v)
}
return listVulnsResult{Vulnerabilities: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "get_server_packages",
Scope: "vulns:read",
Description: "List installed packages on one server, optionally filtered by name. " +
"A server can carry ~2000 packages, so pass name to search rather than listing them all.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
serverID := stringArg(args, "server_id")
if serverID == "" {
return nil, fmt.Errorf("server_id is required")
}
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
return nil, fmt.Errorf("no server %q is visible to this token", serverID)
}
pkgs, err := services.ListPackages(c.InstanceID, serverID)
if err != nil || pkgs == nil {
return nil, fmt.Errorf("no package data for server %q", serverID)
}
nameFilter := strings.ToLower(stringArg(args, "name"))
limit := pageLimit(args)
out := make([]packageEntry, 0, limit)
total := 0
for _, p := range pkgs.Packages {
if nameFilter != "" && !strings.Contains(strings.ToLower(p.Name), nameFilter) {
continue
}
total++
if len(out) < limit {
out = append(out, packageEntry{Name: p.Name, Version: p.Version})
}
}
return serverPackagesResult{ServerID: serverID, Packages: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "search_fleet",
Scope: "vulns:read",
Description: "Search every server's installed packages by name across the whole fleet — " +
"answers questions like \"which hosts still run OpenSSL 1.1\". Pass version_below to " +
"further narrow to versions that sort earlier than the given string.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
name := stringArg(args, "name")
if name == "" {
return nil, fmt.Errorf("name is required")
}
versionBelow := stringArg(args, "version_below")
hits, err := services.SearchPackages(c.InstanceID, name)
if err != nil {
return nil, fmt.Errorf("could not search packages: %w", err)
}
limit := pageLimit(args)
out := make([]packageMatch, 0, limit)
for _, h := range hits {
if len(out) == limit {
break
}
if versionBelow != "" && h.Version >= versionBelow {
continue
}
// A hit's server must be resolved through the token's own
// scope: SearchPackages runs unscoped across the instance,
// so a server outside the token's tag restriction is
// dropped here rather than named to the caller.
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
if err != nil {
continue
}
out = append(out, packageMatch{Hostname: srv.Hostname, Package: h.Name, Version: h.Version})
}
return searchFleetResult{Matches: out, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_audit_events",
Scope: "settings:read",
Description: "List recent audit log events on this instance: who did what, and when. " +
"Filter by event_type prefix (e.g. \"workflow\", \"key\", \"server\").",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
events, _, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
Category: stringArg(args, "event_type"),
Limit: limit,
})
if err != nil {
return nil, fmt.Errorf("could not list audit events: %w", err)
}
out := make([]auditEventSummary, 0, len(events))
for _, e := range events {
out = append(out, auditEventSummary{At: e.CreatedAt, Type: e.EventType, Actor: e.Actor, Detail: e.Details})
}
return listAuditResult{Events: out, Total: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_secret_names",
Scope: "secrets:read",
Description: "List secret group and key names on this instance. Metadata only — no " +
"tool ever returns a secret's plaintext value to a model.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
groups, err := services.ListSecretGroups(c.InstanceID)
if err != nil {
return nil, fmt.Errorf("could not list secret groups: %w", err)
}
out := make([]secretGroupNames, 0, len(groups))
for _, g := range groups {
// GetSecretGroup returns key metadata only (models.Secret's
// EncryptedValue is json:"-"); the plaintext reveal path
// (services.RevealSecret) is never called from this tool.
secrets, err := services.GetSecretGroup(c.InstanceID, g.Group)
if err != nil {
return nil, fmt.Errorf("could not read secret group %q: %w", g.Group, err)
}
keys := make([]string, 0, len(secrets))
for _, s := range secrets {
keys = append(keys, s.Key)
}
out = append(out, secretGroupNames{Group: g.Group, Keys: keys})
}
return listSecretNamesResult{Groups: out}, nil
},
})
}