diff --git a/server/internal/mcp/tools_fleet.go b/server/internal/mcp/tools_fleet.go new file mode 100644 index 0000000..dc83d0b --- /dev/null +++ b/server/internal/mcp/tools_fleet.go @@ -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 + }, + }) +} diff --git a/server/internal/mcp/tools_health.go b/server/internal/mcp/tools_health.go new file mode 100644 index 0000000..1122df5 --- /dev/null +++ b/server/internal/mcp/tools_health.go @@ -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 + }, + }) +} diff --git a/server/internal/mcp/tools_test.go b/server/internal/mcp/tools_test.go new file mode 100644 index 0000000..c18f32d --- /dev/null +++ b/server/internal/mcp/tools_test.go @@ -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)) + } +} diff --git a/server/internal/mcp/tools_work.go b/server/internal/mcp/tools_work.go new file mode 100644 index 0000000..575a26d --- /dev/null +++ b/server/internal/mcp/tools_work.go @@ -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 + }, + }) +}