feat(mcp): add additive fleet-context filtering

get_fleet_context returned the whole registry with no way to narrow it,
so a large fleet produced a payload that exceeded the agent harness's
50KB cap and forced agents to receive or page through healthy resources
that are pure noise from a triage standpoint.

Add optional additive query-param filters — hasFindings, severity,
technology, resourceType — that compose by intersection. All optional
(omitting every filter returns the full fleet, backward compatible);
unknown/unmatched values return 200 with resources: [] (a valid triage
answer, not an error). The hasFindings=true filter is the headline
triage case: show me only what needs attention.

The MCP adapter layer had no GET query-param transport — ProjectCapabilityCall
short-circuited GET after path substitution and dropped all other args.
Close that gap generically: ProjectedCall gains a Query url.Values field
populated from leftover non-path public args for GET/DELETE, and
BuildCapabilityHTTPRequest encodes it onto the request URL. Benefits any
future GET capability with filter args, not just fleet-context. The
resources/list adapter calls fleet-context with empty args and continues
to receive the unfiltered fleet.

The manifest now declares the filters via inputSchema so they are
discoverable through the capability surface the same way add_node's
params are.

- ProjectedCall.Query + GET query-param forwarding (projection.go, http.go)
- fleetContextFilter parsing/matching in HandleFleetContext
- fleetContextInputSchema + InputSchema on the capability
- Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs
- Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery
- Tests: adapter query-forwarding unit tests, 7 filter handler tests
  (hasFindings/severity/technology/resourceType/no-filter/unknown/compose),
  contract pinning inputSchema + end-to-end query forwarding
This commit is contained in:
rcourtman 2026-07-03 18:01:05 +01:00
parent 20c2f27057
commit 313648b2f1
12 changed files with 485 additions and 5 deletions

View file

@ -229,7 +229,7 @@ the live set.
- `get_resource_context` (Get resource context, `GET /api/agent/resource-context/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches. Command fields are redacted for monitoring-read API tokens unless the token also has ai:execute.
- `list_resource_capabilities` (List resource capabilities, `GET /api/agent/resource-capabilities/{resourceId}`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the structured governed capabilities a resource advertises (name, type, approval level, platform, and full parameter schemas). Companion to get_resource_context, which renders capabilities as count-limited prose; this is the structured surface an agent reads before calling plan_action so it can populate capabilityName and params without guessing. A resource with no advertised capabilities returns an empty array.
- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.
- `get_fleet_context` (Get fleet context, `GET /api/agent/fleet-context`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources.
- `get_patrol_control_status` (Get Patrol work status, `GET /api/agent/patrol-control/status`, scope `monitoring:read`, mode `read`, approval `scope_only`): Return the current content-safe Patrol work status: Patrol issue evidence, pending approvals, governed decisions/actions, verified outcomes, Patrol control outcome evidence, compatibility aliases, and optional MCP readiness. The payload is count-only and deliberately omits finding ids, action ids, prompts, commands, resource names, and output.
**Operator state (per-resource intent):**

View file

@ -1670,7 +1670,11 @@ walks the registry once and reuses the in-memory findings index,
one bounded approval-store scan grouped by canonical resource id,
and a per-resource operator-state SQLite point lookup. Agents pick
"where do I focus?" from the fleet view and then drill into the
per-resource bundle for depth.
per-resource bundle for depth. Optional additive filter query params
(hasFindings, severity, technology, resourceType) narrow the sweep to
a relevant subset; they introduce no new lifecycle state, operator
intent, or persistence — they are a read-only projection over the
same in-memory registry walk.
`/api/agent/capabilities` is registered in the router's
`publicPaths` list so the global auth middleware does not gate

View file

@ -806,6 +806,14 @@ projections: `resources/list` must call the canonical `get_fleet_context`
capability, `resources/read` must call `get_resource_context`, and the
`pulse://resource/<resource-id>` URI shape, resource content MIME type, and
resource read parameter validation live in `internal/agentcapabilities` rather
than in `cmd/pulse-mcp` or an MCP-only inventory registry. The
`get_fleet_context` capability accepts optional additive filter arguments
(hasFindings, severity, technology, resourceType) that an agent may pass
through `tools/call` to narrow the fleet to a relevant subset; the projection
layer forwards non-path GET arguments as URL query parameters so the manifest
contract, not a per-capability adapter, owns query-string transport. The
`resources/list` adapter calls fleet-context with no filters and continues to
receive the full fleet.
than in `cmd/pulse-mcp` or an MCP-only inventory registry. They must enter
through `ManifestSurfaceResourceCapabilities`,
`ListMCPManifestSurfaceResourcesHTTP`, and

View file

@ -3693,6 +3693,21 @@ approval counts must come from one org-scoped, resource-keyed scan
of the bounded pending-approval list, not N calls to the
per-resource approval summary helper. Agents pick a focus from the
fleet view, then drill into the per-resource bundle for depth.
`/api/agent/fleet-context` accepts optional additive filter query
parameters — `hasFindings=true` (only resources with at least one
active finding), `severity=critical|warning|info` (at least one
finding at that severity), `technology=<string>`, and
`resourceType=<string>` (case-insensitive exact match). All are
optional and compose by intersection; omitting every filter returns
the full fleet (backward compatible). Unknown or unmatched values
return 200 with `resources: []` rather than an error, because "no
resource matched this filter" is a valid triage answer. The filters
narrow what would otherwise be a full-registry sweep, keeping the
payload bounded for agents triaging a large fleet without paging
through healthy resources; the manifest-owned `inputSchema` declares
them so agents discover the filters through the capability surface,
and non-path GET arguments are forwarded as query parameters by the
shared projection layer rather than a per-capability adapter.
`/api/agent/resource-capabilities/{id}` is the agent-consumable
structured capability surface for a single resource: the governed

View file

@ -1730,7 +1730,11 @@ artifact or storage/recovery freshness signal. The companion
`/api/agent/resource-capabilities/{id}` endpoint is the same kind
of read-only projection: it returns the in-memory
`Resource.Capabilities` slice the registry already assembles, so
no new persistence or recovery artifact is introduced. The recovery posture
no new persistence or recovery artifact is introduced. The
optional additive filter query params on fleet-context
(hasFindings, severity, technology, resourceType) likewise
introduce no new persistence: they only narrow the in-memory
registry walk, so the recovery posture is unchanged. The recovery posture
is identical to the per-resource bundle: when the unified-resources
store and the approval store rehydrate on startup, the fleet view
recovers the same situated picture without any fleet-specific

View file

@ -152,6 +152,12 @@ func BuildCapabilityHTTPRequest(ctx context.Context, baseURL, token string, cap
if err != nil {
return nil, ProjectedCall{}, err
}
// Attach forwarded query parameters (GET/DELETE filter arguments). The
// projection layer owns which arguments become query params; here we only
// encode them onto the request URL.
if len(projected.Query) > 0 {
req.URL.RawQuery = projected.Query.Encode()
}
return req, projected, nil
}

View file

@ -80,6 +80,73 @@ func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) {
}
}
func TestBuildCapabilityHTTPRequestForwardsGETFiltersAsQuery(t *testing.T) {
// A GET capability with non-path arguments must forward them as URL query
// parameters (not a body, not dropped) so filter-style tools like
// get_fleet_context can receive agent-supplied filters through the MCP
// tools/call path.
cap := Capability{
Name: FleetContextCapabilityName,
Method: http.MethodGet,
Path: FleetContextCapabilityPath,
}
req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{
"hasFindings": "true",
"technology": "docker",
})
if err != nil {
t.Fatalf("BuildCapabilityHTTPRequest: %v", err)
}
if req.Method != http.MethodGet {
t.Fatalf("method = %q, want GET", req.Method)
}
if projected.HasBody {
t.Fatalf("GET projection must not produce a body; projected = %+v", projected)
}
if projected.Query == nil {
t.Fatal("projected.Query must be populated for a GET with filter args")
}
if got := projected.Query.Get("hasFindings"); got != "true" {
t.Errorf("query hasFindings = %q, want true", got)
}
if got := projected.Query.Get("technology"); got != "docker" {
t.Errorf("query technology = %q, want docker", got)
}
// The query must land on the request URL encoded.
if req.URL.RawQuery == "" {
t.Fatalf("RawQuery empty; url = %q", req.URL.String())
}
gotFindings := req.URL.Query().Get("hasFindings")
gotTech := req.URL.Query().Get("technology")
if gotFindings != "true" || gotTech != "docker" {
t.Errorf("url query = hasFindings=%q technology=%q", gotFindings, gotTech)
}
if req.Body != nil {
t.Fatalf("GET request must not carry a body; got %+v", req.Body)
}
}
func TestBuildCapabilityHTTPRequestGETWithoutArgsHasNoQuery(t *testing.T) {
// A GET with no filter arguments must not synthesize a query string —
// backward compatibility for the resources/list adapter and any caller
// that wants the unfiltered result.
cap := Capability{
Name: FleetContextCapabilityName,
Method: http.MethodGet,
Path: FleetContextCapabilityPath,
}
req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "token", cap, map[string]any{})
if err != nil {
t.Fatalf("BuildCapabilityHTTPRequest: %v", err)
}
if projected.Query != nil {
t.Fatalf("projected.Query = %+v, want nil for argless GET", projected.Query)
}
if req.URL.RawQuery != "" {
t.Fatalf("RawQuery = %q, want empty", req.URL.RawQuery)
}
}
func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) {
var got struct {
Method string

View file

@ -30,6 +30,31 @@ func fleetContextOutputSchema() json.RawMessage {
})
}
// fleetContextInputSchema declares the optional additive filters for
// get_fleet_context. None are required; omitting all returns the full fleet
// (backward compatible). Forwarded as URL query parameters for the GET.
func fleetContextInputSchema() json.RawMessage {
return agentObjectInputSchema(nil, map[string]any{
"hasFindings": map[string]any{
"type": "boolean",
"description": "When true, return only resources with at least one active finding. Omit to include healthy resources.",
},
"severity": map[string]any{
"type": "string",
"enum": []string{"critical", "warning", "info"},
"description": "Return only resources with at least one finding at this severity. Composes with hasFindings and the dimension filters.",
},
"technology": map[string]any{
"type": "string",
"description": "Return only resources whose technology matches exactly (case-insensitive), such as proxmox, docker, lxc, qemu.",
},
"resourceType": map[string]any{
"type": "string",
"description": "Return only resources whose canonical type matches exactly (case-insensitive), such as vm, system-container, app-container, agent, storage.",
},
})
}
func operationsLoopStatusOutputSchema() json.RawMessage {
return agentObjectOutputSchema([]string{"nextAction", "progressLabel", "steps", "patrolEvidenceCount", "patrolIssueEvidenceCount", "activeFindingCount", "pendingApprovalCount", "governedActionCount", "approvedDecisionCount", "rejectedDecisionCount", "verifiedOutcomeCount", "operationsLoopStarterCount", "assistantOperationsLoopStarterCount", "patrolOperationsLoopStarterCount", "patrolControlOperationsLoopStarterCount", "patrolControlCompletedOperationsLoopCount", "patrolControlResolvedOperationsLoopCount", "patrolControlValueState", "patrolAutonomyOperationsLoopStarterCount", "patrolAutonomyCompletedOperationsLoopCount", "patrolAutonomyResolvedOperationsLoopCount", "patrolAutonomyValueState", "proActivationOperationsLoopStarterCount", "proActivationCompletedOperationsLoopCount", "proActivationResolvedOperationsLoopCount", "proActivationValueProofState", "mcpOperationsLoopStarterCount", "externalAgentReady", "windowStart", "generatedAt"}, map[string]any{
"nextAction": stringEnumOption([]string{"run_patrol", "review_findings", "open_assistant", "review_approvals", "open_mcp", "complete"}, "Next recommended Patrol work action an agent or UI should take."),
@ -721,7 +746,7 @@ var canonicalManifest = Manifest{
{
Name: "get_fleet_context",
Title: "Get fleet context",
Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.",
Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth. Optional additive filters (hasFindings, severity, technology, resourceType) narrow the result to a relevant subset so agents triaging a large fleet do not receive or page through healthy resources.",
Category: "context",
Method: http.MethodGet,
Path: FleetContextCapabilityPath,
@ -729,6 +754,7 @@ var canonicalManifest = Manifest{
ActionMode: agentCapabilityActionModeRead,
ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly,
ResponseShape: "AgentFleetContext",
InputSchema: fleetContextInputSchema(),
OutputSchema: fleetContextOutputSchema(),
},
{

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
)
@ -516,6 +517,11 @@ type ProjectedCall struct {
Path string
Body json.RawMessage
HasBody bool
// Query carries filter arguments forwarded as URL query parameters for
// GET/DELETE capabilities (which have no request body). Populated from
// leftover public arguments after path-parameter substitution. Empty for
// capabilities that declare no filter arguments.
Query url.Values
}
// ProjectCapabilityCall turns agent-facing tool arguments into the Pulse HTTP
@ -528,6 +534,25 @@ func ProjectCapabilityCall(cap Capability, args map[string]any) (ProjectedCall,
projected := ProjectedCall{Path: path}
if !CapabilityHasRequestBody(cap) {
// GET/DELETE carry non-path arguments as URL query parameters rather
// than a JSON body. Collect leftover public arguments after path
// substitution so filter-style capabilities (e.g. get_fleet_context)
// can forward filters without the agent constructing a raw query
// string. Empty when no non-path arguments are supplied — preserving
// the prior unfiltered behavior.
pathParams := PathParameterSet(cap.Path)
query := url.Values{}
for k, v := range PublicToolArguments(args) {
if pathParams[k] {
continue
}
if s, ok := v.(string); ok {
query.Set(k, s)
}
}
if len(query) > 0 {
projected.Query = query
}
return projected, nil
}

View file

@ -739,6 +739,13 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http.
}
}
// Optional additive filters. All empty/unset = current behavior (return
// the full fleet). Unknown values match nothing and return an empty
// resources array, which is a valid triage answer, not an error: an agent
// asking for technology=docker on a fleet with none should see [], not a
// 400. String filters are case-insensitive and trimmed.
filter := parseFleetContextFilter(r)
for _, resource := range resources {
canonical := unified.CanonicalResourceID(resource.ID)
summary := AgentFleetResourceSummary{
@ -759,6 +766,9 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http.
if count, ok := pendingByResource[canonical]; ok {
summary.PendingApprovalCount = count
}
if !fleetContextFilterMatches(filter, summary) {
continue
}
out.Resources = append(out.Resources, summary)
}
@ -766,7 +776,88 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http.
_ = json.NewEncoder(w).Encode(out)
}
// HandleOperationsLoopStatus serves the content-safe status read for the shared
// fleetContextFilter captures the optional, additive query-param filters for
// GET /api/agent/fleet-context. A nil/zero field means "no filter on this
// dimension." Empty values (hasFindingsFilter set but severity empty, etc.)
// still match per their semantics below.
type fleetContextFilter struct {
// hasFindingsFilter is set when the hasFindings query param is present.
// When true, only resources with at least one active finding are kept.
// When false/absent, no finding-presence filter is applied.
hasFindingsFilter bool
hasFindings bool
// severity, when non-empty, keeps only resources with at least one finding
// at that severity (critical/warning/info). Case-insensitive, trimmed.
severity string
// technology, when non-empty, keeps only resources whose Technology matches
// (case-insensitive, trimmed). Empty technology on a resource does not match
// a non-empty filter.
technology string
// resourceType, when non-empty, keeps only resources whose Type matches
// (case-insensitive, trimmed).
resourceType string
}
// parseFleetContextFilter reads optional fleet-context filter query params.
// follows the bare r.URL.Query().Get convention used elsewhere in the api
// package. Invalid values are silently ignored (no 400) so the endpoint
// degrades gracefully to a broader result rather than erroring on a typo.
func parseFleetContextFilter(r *http.Request) fleetContextFilter {
q := r.URL.Query()
f := fleetContextFilter{}
if raw := strings.TrimSpace(q.Get("hasFindings")); raw != "" {
// Bare sentinel match, matching the codebase convention (e.g. == "1").
// Any value other than "true"/"1" is treated as absent to avoid
// erroring on an unfamiliar truthy spelling.
if raw == "true" || raw == "1" {
f.hasFindingsFilter = true
f.hasFindings = true
}
}
f.severity = strings.ToLower(strings.TrimSpace(q.Get("severity")))
f.technology = strings.ToLower(strings.TrimSpace(q.Get("technology")))
f.resourceType = strings.ToLower(strings.TrimSpace(q.Get("resourceType")))
return f
}
// fleetContextFilterMatches reports whether a resource summary passes the
// active fleet-context filter. An empty filter matches everything (the
// backward-compatible unfiltered behavior).
func fleetContextFilterMatches(f fleetContextFilter, s AgentFleetResourceSummary) bool {
if f.hasFindingsFilter && f.hasFindings && s.Findings.Total == 0 {
return false
}
if f.severity != "" {
switch f.severity {
case "critical":
if s.Findings.Critical == 0 {
return false
}
case "warning":
if s.Findings.Warning == 0 {
return false
}
case "info":
if s.Findings.Info == 0 {
return false
}
default:
// Unknown severity matches nothing — a valid empty result, not an
// error. An agent asking for severity=blocker on a fleet that
// uses critical/warning/info should see [], signaling the filter
// did not apply, rather than the whole fleet.
return false
}
}
if f.technology != "" && strings.ToLower(s.Technology) != f.technology {
return false
}
if f.resourceType != "" && strings.ToLower(s.ResourceType) != f.resourceType {
return false
}
return true
}
// Pulse Intelligence Patrol control loop. The canonical route is
// `GET /api/agent/patrol-control/status`; `GET /api/agent/operations-loop/status`
// remains as a compatibility alias. It deliberately summarizes counts and

View file

@ -1396,6 +1396,189 @@ func TestHandleAgentFleetContext_RejectsNonGet(t *testing.T) {
}
}
// fleetFilterFixture seeds a mixed fleet for filter tests: one healthy VM
// (vm:healthy, no findings), one degraded VM (vm:critical, a critical finding),
// one docker container (container:web, a warning finding), and one LXC
// (container:lxcone, info finding). Covers every filter dimension.
func fleetFilterFixture(t *testing.T) *AgentContextHandler {
t.Helper()
h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{
{ID: "vm:healthy", Type: "vm", Name: "healthy-vm", Technology: "qemu"},
{ID: "vm:critical", Type: "vm", Name: "critical-vm", Technology: "qemu"},
{ID: "container:web", Type: "container", Name: "web", Technology: "docker"},
{ID: "container:lxcone", Type: "system-container", Name: "lxcone", Technology: "lxc"},
})
findings.byResource["vm:critical"] = []AgentResourceFindingSnapshot{
{ID: "c1", Severity: "critical"},
}
findings.byResource["container:web"] = []AgentResourceFindingSnapshot{
{ID: "w1", Severity: "warning"},
}
findings.byResource["container:lxcone"] = []AgentResourceFindingSnapshot{
{ID: "i1", Severity: "info"},
}
return h
}
func decodeFleetResponse(t *testing.T, rec *httptest.ResponseRecorder) AgentFleetContext {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
}
var fleet AgentFleetContext
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
t.Fatalf("decode: %v", err)
}
return fleet
}
func fleetCanonicalIDs(fleet AgentFleetContext) []string {
ids := make([]string, 0, len(fleet.Resources))
for _, r := range fleet.Resources {
ids = append(ids, r.CanonicalID)
}
return ids
}
func TestHandleAgentFleetContext_HasFindingsFilter(t *testing.T) {
// hasFindings=true must drop healthy resources and keep only those with at
// least one finding — the headline triage filter. vm:healthy has none and
// must be excluded.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
got := fleetCanonicalIDs(fleet)
want := []string{"vm:critical", "container:web", "container:lxcone"}
if len(got) != len(want) {
t.Fatalf("hasFindings=true returned %v; want %v (healthy must be excluded)", got, want)
}
gotSet := map[string]bool{}
for _, id := range got {
gotSet[id] = true
}
for _, id := range want {
if !gotSet[id] {
t.Errorf("hasFindings=true missing %s; got %v", id, got)
}
}
if gotSet["vm:healthy"] {
t.Errorf("vm:healthy must be excluded by hasFindings=true; got %v", got)
}
}
func TestHandleAgentFleetContext_SeverityFilter(t *testing.T) {
// severity=critical must keep only resources with a critical finding.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=critical", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
got := fleetCanonicalIDs(fleet)
if len(got) != 1 || got[0] != "vm:critical" {
t.Fatalf("severity=critical returned %v; want [vm:critical]", got)
}
// severity=warning must keep only the warning-bearing resource.
h2 := fleetFilterFixture(t)
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=warning", nil)
h2.HandleFleetContext(rec2, req2)
fleet2 := decodeFleetResponse(t, rec2)
got2 := fleetCanonicalIDs(fleet2)
if len(got2) != 1 || got2[0] != "container:web" {
t.Fatalf("severity=warning returned %v; want [container:web]", got2)
}
}
func TestHandleAgentFleetContext_TechnologyFilter(t *testing.T) {
// technology=docker (case-insensitive) must keep only the docker container.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=docker", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
got := fleetCanonicalIDs(fleet)
if len(got) != 1 || got[0] != "container:web" {
t.Fatalf("technology=docker returned %v; want [container:web]", got)
}
// Case-insensitivity: DOCKER must match the same resource.
h2 := fleetFilterFixture(t)
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=DOCKER", nil)
h2.HandleFleetContext(rec2, req2)
fleet2 := decodeFleetResponse(t, rec2)
got2 := fleetCanonicalIDs(fleet2)
if len(got2) != 1 || got2[0] != "container:web" {
t.Fatalf("technology=DOCKER returned %v; want [container:web] (case-insensitive)", got2)
}
}
func TestHandleAgentFleetContext_ResourceTypeFilter(t *testing.T) {
// resourceType=vm must keep only VMs (the two qemu VMs).
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?resourceType=vm", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
got := fleetCanonicalIDs(fleet)
if len(got) != 2 {
t.Fatalf("resourceType=vm returned %v; want 2 vms", got)
}
gotSet := map[string]bool{}
for _, id := range got {
gotSet[id] = true
}
if !gotSet["vm:healthy"] || !gotSet["vm:critical"] {
t.Errorf("resourceType=vm must return both vms; got %v", got)
}
}
func TestHandleAgentFleetContext_NoFilterReturnsAll(t *testing.T) {
// Backward compatibility: no query params returns the full fleet.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
if len(fleet.Resources) != 4 {
t.Fatalf("no-filter fleet returned %d resources; want 4 (backward compat)", len(fleet.Resources))
}
}
func TestHandleAgentFleetContext_UnknownFilterValueReturnsEmpty(t *testing.T) {
// An unknown technology must return 200 with an empty array, not a 400.
// The signal "no resource matched this filter" is a valid triage answer.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=nonexistent", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
if fleet.Resources == nil {
t.Fatal("resources is null; want non-nil empty array")
}
if len(fleet.Resources) != 0 {
t.Errorf("unknown technology returned %d resources; want 0", len(fleet.Resources))
}
}
func TestHandleAgentFleetContext_FiltersCompose(t *testing.T) {
// Composed filters must intersect: hasFindings=true AND technology=qemu
// returns only the degraded VM (vm:critical), excluding the healthy qemu
// VM and the non-qemu degraded resources.
h := fleetFilterFixture(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true&technology=qemu", nil)
h.HandleFleetContext(rec, req)
fleet := decodeFleetResponse(t, rec)
got := fleetCanonicalIDs(fleet)
if len(got) != 1 || got[0] != "vm:critical" {
t.Fatalf("composed filter returned %v; want [vm:critical] (degraded qemu only)", got)
}
}
func TestHandleAgentOperationsLoopStatus_StartsAtPatrolWithoutIssueEvidence(t *testing.T) {
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
{ID: "vm:101", Type: "vm", Name: "db-01"},

View file

@ -19391,6 +19391,57 @@ func TestContract_GetFleetContextCapabilityListed(t *testing.T) {
}
}
// TestContract_GetFleetContextDeclaresAdditiveFilters pins the agent-surface
// contract for fleet triage at scale: the capability must declare optional
// additive filter arguments (hasFindings, severity, technology, resourceType)
// so an agent can narrow a large fleet to a relevant subset without paging,
// and the GET projection layer must forward non-path arguments as URL query
// parameters (not drop them, not body them). Without the inputSchema the
// filters are undiscoverable; without query forwarding they are unreachable
// through the MCP tools/call path.
func TestContract_GetFleetContextDeclaresAdditiveFilters(t *testing.T) {
manifest := agentcapabilities.CanonicalManifest()
cap, ok := agentcapabilities.FindCapability(manifest.Capabilities, agentcapabilities.FleetContextCapabilityName)
if !ok {
t.Fatalf("manifest missing %s", agentcapabilities.FleetContextCapabilityName)
}
if len(cap.InputSchema) == 0 {
t.Fatalf("%s must declare an inputSchema so filter arguments are discoverable through the manifest", agentcapabilities.FleetContextCapabilityName)
}
var schema map[string]any
if err := json.Unmarshal(cap.InputSchema, &schema); err != nil {
t.Fatalf("%s inputSchema must be valid JSON: %v", agentcapabilities.FleetContextCapabilityName, err)
}
properties, _ := schema["properties"].(map[string]any)
for _, filter := range []string{"hasFindings", "severity", "technology", "resourceType"} {
if _, ok := properties[filter]; !ok {
t.Errorf("%s inputSchema must declare filter %q; properties = %v", agentcapabilities.FleetContextCapabilityName, filter, properties)
}
}
// None of the filters are required — omitting all returns the full fleet.
if required, _ := schema["required"].([]any); len(required) != 0 {
t.Errorf("%s filter args must all be optional (backward compat); required = %v", agentcapabilities.FleetContextCapabilityName, required)
}
// The GET projection must forward non-path arguments as query params. Pin
// the contract end-to-end: a fleet-context call with filter args produces
// a request whose RawQuery carries them.
req, projected, err := agentcapabilities.BuildCapabilityHTTPRequest(
context.Background(), "http://pulse.local/", "token", cap, map[string]any{
"hasFindings": "true",
"technology": "docker",
})
if err != nil {
t.Fatalf("BuildCapabilityHTTPRequest: %v", err)
}
if projected.Query == nil || projected.Query.Get("hasFindings") != "true" || projected.Query.Get("technology") != "docker" {
t.Fatalf("GET projection must forward filter args as query params; projected = %+v", projected)
}
if req.URL.RawQuery == "" {
t.Fatalf("filter args must reach the request URL; url = %q", req.URL.String())
}
}
// TestContract_FindingsResourceOperatorStateProviderIsWired pins the
// startup wiring that gives the findings runtime access to
// per-resource operator-set state via the API layer's adapter. The