feat(mcp): add list_resource_capabilities tool

Agents had no way to discover which governed capabilities a resource
advertises before calling plan_action. The data existed (Resource.Capabilities
with full param schemas) but was only surfaced as count-limited prose
facts inside get_resource_context, which deliberately omits parameter
schemas. This forced agents to guess capabilityName and params.

Add a dedicated structured surface: GET /api/agent/resource-capabilities/{id}
returns the canonical ResourceCapability list (name, type, approval level,
platform, full parameter schemas) for a single resource. A resource with
no advertised capabilities returns 200 with an empty array, the signal
to skip plan_action.

Internal plumbing already existed (registry.Get + Resource.Capabilities,
the same path plan_action validates against); this is the wiring work at
the canonical agent-surface layer. The tools/call dispatch is fully
generic so no mcp.go changes were needed.

Companion to get_resource_context: that tool remains the human-readable
prose summary; this is the structured schema surface for action planning.

- Manifest capability, name/path constants, output schema helper
- Handler mirroring HandleResourceContext error/scope patterns
- Route registration behind monitoring:read
- Activity telemetry mapping to resource_context class
- Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs
- Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery
- Tests: manifest ownership/addressing/table-test, handler happy/empty/404/405,
  contract pinning capability presence/scope/wire-shape
This commit is contained in:
rcourtman 2026-07-03 17:07:41 +01:00
parent b1fdefdd50
commit 20c2f27057
15 changed files with 404 additions and 19 deletions

View file

@ -228,6 +228,7 @@ the live set.
**Context (read-only):**
- `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_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.
@ -311,6 +312,7 @@ Capability-specific stable codes are advertised by the manifest:
<!-- pulse-mcp-errors:start -->
- `get_resource_context`: `resource_not_found`
- `list_resource_capabilities`: `resource_not_found`
- `get_operator_state`: `operator_state_not_set`
- `set_operator_state`: `operator_state_invalid`
- `acknowledge_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`

View file

@ -1651,7 +1651,13 @@ import; the parallel `AgentApprovalsProvider` adapter resolves the
canonical approval store at request time, filters full per-resource
summaries by canonical resource id and org, and exposes a separate
resource-keyed count projection for fleet reads. The agent runtime
keeps both providers wired across restarts.
keeps both providers wired across restarts. The companion
`/api/agent/resource-capabilities/{id}` endpoint (same
`monitoring:read` scope) returns the structured governed
capability list and parameter schemas for the same resource, so an
agent can populate `plan_action` inputs from the canonical
`Resource.Capabilities` source rather than the prose summary in
the context bundle.
`/api/agent/fleet-context` is the companion triage view: one read
returns a thin per-resource rollup across every resource visible

View file

@ -361,7 +361,12 @@ older clients. That shared Patrol work tool set starts with
`get_patrol_control_status`, the count-only
`GET /api/agent/patrol-control/status` orientation read, then uses the same
fleet-context, resource-context, finding, governed action, and finding
resolution tools as the rest of the governed issue-handling flow.
resolution tools as the rest of the governed issue-handling flow. The
structured `list_resource_capabilities` read is the companion to
resource-context for action planning: it returns the per-resource
governed capability names and parameter schemas an agent needs to
populate `plan_action` inputs without guessing, where resource-context
renders capabilities only as count-limited prose facts.
`get_operations_loop_status` and `/api/agent/operations-loop/status` are
compatibility aliases, not primary capability names. AI runtime prompts, MCP
readiness helpers, and frontend presentation helpers must not carry separate

View file

@ -1878,11 +1878,11 @@ a new API state machine, queue contract, or verification-accounting field.
overdue scheduled protection only from API-owned Patrol status, run history,
approval state, and finding-composition facts rather than page-local
inference
and the calm-day Patrol protection-posture inputs, where the browser may
summarize protection-current, coverage, schedule freshness, drift history,
and verification-waiting state only from API-owned Patrol status, run
history, approval state, and finding-composition facts after current work has
been ruled out
and the calm-day Patrol empty queue boundary, where the browser must not
turn API-owned Patrol status, run history, approval state, or
finding-composition facts into a protection-current, coverage,
schedule-freshness, drift-history, verification-waiting, trust, or proof
strip after current work has been ruled out
while keeping the learning counters backend-only coverage, so Patrol keeps health and findings primary and renders timeline, correlation, and policy-posture data as selected-item investigation context rather than as a separate headline product metric
and that secondary investigation context remains explanatory API evidence,
not a default workspace mode: the Patrol page may expand recent-change,
@ -3445,7 +3445,7 @@ There are two layers of stable codes. First, **capability-specific
codes** are declared per capability in the manifest's `errorCodes`
list and represent the closed set of failure modes that
capability's handler emits. Today these are:
`resource_not_found` (`get_resource_context`),
`resource_not_found` (`get_resource_context`, `list_resource_capabilities`),
`operator_state_not_set` (`get_operator_state`),
`operator_state_invalid` (`set_operator_state`);
`invalid_finding_request`, `finding_not_found`,
@ -3694,6 +3694,24 @@ 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/resource-capabilities/{id}` is the agent-consumable
structured capability surface for a single resource: the governed
capabilities it advertises (name, type, approval level, platform, and
full parameter schemas). It is the companion to the resource-context
bundle: the bundle renders capabilities as count-limited prose facts
(`formatCapabilityFact` omits parameter schemas and caps at five
entries), while this endpoint returns the structured list an agent
needs to populate `plan_action.capabilityName` and `params` without
guessing. The capability list is the in-memory
`Resource.Capabilities` slice assembled by the registry — the same
canonical source `plan_action` validates against — so the two
endpoints cannot drift on what a resource can do. A resource
advertising nothing returns 200 with `capabilities: []`, the signal
an agent should not attempt `plan_action` against it; the array is
always non-nil on the wire. The internal `InternalHandler` field is
excluded from JSON via its `json:"-"` tag so execution plumbing does
not leak to the public surface.
The TS client `frontend-modern/src/api/resourceOperatorState.ts`
mirrors the canonical Go shape from
`internal/unifiedresources/resource_operator_state.go` and exposes

View file

@ -1726,7 +1726,11 @@ in-memory findings store, and the same in-memory approval store.
Fleet pending-approval counts may be grouped by canonical resource
id from one bounded approval-store scan, but that is still a
read-only projection over `approvals.json`, not a new recovery
artifact or storage/recovery freshness signal. The recovery posture
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
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

@ -80,6 +80,14 @@ func resourceContextOutputSchema() json.RawMessage {
})
}
func resourceCapabilitiesOutputSchema() json.RawMessage {
return agentObjectOutputSchema([]string{"resourceId", "capabilities", "generatedAt"}, map[string]any{
"resourceId": stringOption("Canonical Pulse resource id the capabilities belong to."),
"capabilities": objectArrayOption("Advertised governed capabilities with full parameter schemas; empty array when the resource advertises none."),
"generatedAt": dateTimeOption("Server timestamp for this capability snapshot."),
})
}
func operatorStateOutputSchema() json.RawMessage {
return agentObjectOutputSchema([]string{"canonicalId", "intentionallyOffline", "neverAutoRemediate", "setAt"}, map[string]any{
"canonicalId": stringOption("Canonical Pulse resource id."),
@ -696,6 +704,20 @@ var canonicalManifest = Manifest{
OutputSchema: resourceContextOutputSchema(),
ErrorCodes: []string{AgentErrCodeResourceNotFound},
},
{
Name: ListResourceCapabilitiesCapabilityName,
Title: "List resource capabilities",
Description: "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.",
Category: "context",
Method: http.MethodGet,
Path: ListResourceCapabilitiesCapabilityPath,
Scope: agentCapabilityScopeMonitoringRead,
ActionMode: agentCapabilityActionModeRead,
ApprovalPolicy: agentCapabilityApprovalPolicyScopeOnly,
ResponseShape: "AgentResourceCapabilities",
OutputSchema: resourceCapabilitiesOutputSchema(),
ErrorCodes: []string{AgentErrCodeResourceNotFound},
},
{
Name: "get_fleet_context",
Title: "Get fleet context",
@ -1045,6 +1067,7 @@ func CanonicalManifest() Manifest {
func canonicalPulseMCPSurfaceToolNames() []string {
return []string{
ResourceContextCapabilityName,
ListResourceCapabilitiesCapabilityName,
FleetContextCapabilityName,
OperationsLoopStatusCapabilityName,
ListNodesCapabilityName,

View file

@ -23,6 +23,7 @@ func TestCanonicalManifestOwnsAgentSurface(t *testing.T) {
for _, name := range []string{
FleetContextCapabilityName,
ResourceContextCapabilityName,
ListResourceCapabilitiesCapabilityName,
OperationsLoopStatusCapabilityName,
SetOperatorStateCapabilityName,
ListFindingsCapabilityName,
@ -72,12 +73,13 @@ func TestCanonicalManifestUsesSharedResourceContextAddressing(t *testing.T) {
manifest := CanonicalManifest()
for name, path := range map[string]string{
FleetContextCapabilityName: FleetContextCapabilityPath,
ResourceContextCapabilityName: ResourceContextCapabilityPath,
OperationsLoopStatusCapabilityName: OperationsLoopStatusCapabilityPath,
GetOperatorStateCapabilityName: OperatorStateCapabilityPath,
SetOperatorStateCapabilityName: OperatorStateCapabilityPath,
ClearOperatorStateCapabilityName: OperatorStateCapabilityPath,
FleetContextCapabilityName: FleetContextCapabilityPath,
ResourceContextCapabilityName: ResourceContextCapabilityPath,
ListResourceCapabilitiesCapabilityName: ListResourceCapabilitiesCapabilityPath,
OperationsLoopStatusCapabilityName: OperationsLoopStatusCapabilityPath,
GetOperatorStateCapabilityName: OperatorStateCapabilityPath,
SetOperatorStateCapabilityName: OperatorStateCapabilityPath,
ClearOperatorStateCapabilityName: OperatorStateCapabilityPath,
} {
cap, ok := FindCapability(manifest.Capabilities, name)
if !ok {
@ -415,6 +417,16 @@ func TestCanonicalManifestPinsPulseMCPResolvedOperationsLoopCapabilities(t *test
approval: ApprovalPolicyScopeOnly,
errorCodes: []string{AgentErrCodeResourceNotFound},
},
{
name: ListResourceCapabilitiesCapabilityName,
category: "context",
method: http.MethodGet,
path: ListResourceCapabilitiesCapabilityPath,
scope: auth.ScopeMonitoringRead,
mode: ActionModeRead,
approval: ApprovalPolicyScopeOnly,
errorCodes: []string{AgentErrCodeResourceNotFound},
},
{
name: ListFindingsCapabilityName,
category: "finding",

View file

@ -13,6 +13,12 @@ const (
FleetContextCapabilityName = "get_fleet_context"
// ResourceContextCapabilityName is the manifest-owned per-resource depth read.
ResourceContextCapabilityName = "get_resource_context"
// ListResourceCapabilitiesCapabilityName is the manifest-owned per-resource
// structured capability surface (names + parameter schemas). Companion to
// get_resource_context: that tool renders capabilities as count-limited prose
// facts (omitting parameter schemas); this tool returns the structured list
// an agent needs to populate plan_action.capabilityName and params.
ListResourceCapabilitiesCapabilityName = "list_resource_capabilities"
// PatrolControlStatusCapabilityName is the manifest-owned Patrol control
// status read. It is count-only and content-safe so external agents can
// orient on the same Patrol -> Assistant -> governed action ->
@ -30,6 +36,9 @@ const (
FleetContextCapabilityPath = "/api/agent/fleet-context"
// ResourceContextCapabilityPath is the manifest-owned per-resource context route.
ResourceContextCapabilityPath = "/api/agent/resource-context/{" + ResourceIDArgumentName + "}"
// ListResourceCapabilitiesCapabilityPath is the manifest-owned per-resource
// capability surface route.
ListResourceCapabilitiesCapabilityPath = "/api/agent/resource-capabilities/{" + ResourceIDArgumentName + "}"
// PatrolControlStatusCapabilityPath is the manifest-owned Patrol control
// status route.
PatrolControlStatusCapabilityPath = "/api/agent/patrol-control/status"

View file

@ -50,7 +50,8 @@ func externalAgentCapabilityActivity(capabilityName string) (string, string, boo
}
var activity string
switch strings.TrimSpace(capabilityName) {
case agentcapabilities.ResourceContextCapabilityName:
case agentcapabilities.ResourceContextCapabilityName,
agentcapabilities.ListResourceCapabilitiesCapabilityName:
activity = config.ExternalAgentActivityResourceContext
case agentcapabilities.FleetContextCapabilityName:
activity = config.ExternalAgentActivityFleetContext

View file

@ -202,9 +202,10 @@ func TestAgentCapabilitiesManifest_EveryCapabilityDeclaresMethodPathScope(t *tes
func TestAgentCapabilitiesManifest_ScopesMatchAPIAuthConstants(t *testing.T) {
expected := map[string]string{
agentcapabilities.ResourceContextCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.ResourceContextCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.ListResourceCapabilitiesCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.FleetContextCapabilityName: config.ScopeMonitoringRead,
agentcapabilities.OperationsLoopStatusCapabilityName: config.ScopeMonitoringRead,
"list_nodes": config.ScopeSettingsRead,
"add_node": config.ScopeSettingsWrite,
"update_node": config.ScopeSettingsWrite,

View file

@ -275,6 +275,17 @@ type AgentResourceContext struct {
GeneratedAt time.Time `json:"generatedAt"`
}
// AgentResourceCapabilities is the structured capability surface for a single
// resource: the names and parameter schemas an agent needs to populate
// plan_action.capabilityName and params. Mirrors Resource.Capabilities but is
// projected at request time so the wire shape stays stable. The internal
// execution handler is excluded via its `json:"-"` tag.
type AgentResourceCapabilities struct {
ResourceID string `json:"resourceId"`
Capabilities []unified.ResourceCapability `json:"capabilities"`
GeneratedAt time.Time `json:"generatedAt"`
}
// AgentFindingsProvider returns active findings as agent-stable snapshots and
// count-only aggregate evidence. The implementation lives outside this package
// (the patrol service holds the canonical findings store); this interface keeps
@ -579,6 +590,65 @@ func buildAgentResourceContextSections(resource unified.Resource, store unified.
})
}
// HandleResourceCapabilities serves
// `GET /api/agent/resource-capabilities/{id}` — the structured
// capability surface for a single resource: the names and parameter
// schemas an agent reads before calling plan_action. Companion to the
// resource-context bundle, which renders capabilities as count-limited
// prose facts (omitting parameter schemas). A resource with no
// advertised capabilities returns 200 with an empty capabilities array,
// which is the signal an agent should not attempt plan_action against it.
//
// Computed at request time (no caching). The capability list is the
// in-memory Resource.Capabilities slice assembled by the registry, so
// this endpoint only reads the canonical source of truth that
// plan_action validates against.
func (h *AgentContextHandler) HandleResourceCapabilities(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.resources == nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
resourceID := extractAgentResourceCapabilitiesID(r.URL.Path)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
orgID := GetOrgID(r.Context())
registry, err := h.resources.buildRegistry(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
resource, resourceID, ok := presentationResourceByReference(registry, resourceID)
if !ok {
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeResourceNotFound,
"No resource is registered with this canonical id.")
return
}
// Capabilities default to a non-nil empty array so the wire shape is
// always a JSON array, never null. A resource advertising nothing is a
// valid, actionable answer (the signal to skip plan_action).
capabilities := []unified.ResourceCapability{}
if len(resource.Capabilities) > 0 {
capabilities = append(capabilities, resource.Capabilities...)
}
bundle := AgentResourceCapabilities{
ResourceID: resourceID,
Capabilities: capabilities,
GeneratedAt: time.Now().UTC(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(bundle)
}
func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *agentcontext.OperatorState {
if state == nil {
return nil
@ -604,6 +674,15 @@ func extractAgentResourceContextID(path string) string {
return unified.CanonicalResourceID(trimmed)
}
// extractAgentResourceCapabilitiesID parses the canonical resource ID out
// of the resource-capabilities URL path. Same trim/canonicalize pattern as
// extractAgentResourceContextID against the capabilities route prefix.
func extractAgentResourceCapabilitiesID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/agent/resource-capabilities/")
trimmed = strings.TrimSuffix(trimmed, "/")
return unified.CanonicalResourceID(trimmed)
}
// HandleFleetContext serves
// `GET /api/agent/fleet-context` — the agent-consumable triage view
// across every resource visible to the org. Each entry is a thin

View file

@ -751,6 +751,156 @@ func TestHandleAgentResourceContext_RejectsNonGet(t *testing.T) {
}
}
func TestHandleAgentResourceCapabilities_ReturnsStructuredCapabilitiesAndParams(t *testing.T) {
// Stage a resource advertising one capability with typed params. The
// response must surface the full param schema (what formatCapabilityFact
// deliberately omits in the resource-context bundle) so an agent can
// populate plan_action.params without guessing. The internal handler
// field must be absent from the JSON.
resource := unified.Resource{
ID: "vm:101",
Type: "vm",
Name: "db-01",
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeNative,
Description: "Restart the guest workload.",
MinimumApprovalLevel: unified.ApprovalAdmin,
Platform: "qemu",
InternalHandler: "shouldNotAppearOnWire",
Params: []unified.CapabilityParam{
{
Name: "force",
Type: "boolean",
Required: false,
Description: "Force a hard restart when the graceful stop times out.",
},
{
Name: "timeout",
Type: "int",
Required: true,
Enum: []string{"30", "60", "120"},
Pattern: `^\d+$`,
},
},
},
},
}
h, _ := agentContextFixtureHandlersForResource(t, resource)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
h.HandleResourceCapabilities(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
}
var bundle AgentResourceCapabilities
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
t.Fatalf("unmarshal bundle: %v", err)
}
if bundle.ResourceID != "vm:101" {
t.Errorf("resourceId: got %q want vm:101", bundle.ResourceID)
}
if len(bundle.Capabilities) != 1 {
t.Fatalf("capabilities: got %d want 1", len(bundle.Capabilities))
}
cap := bundle.Capabilities[0]
if cap.Name != "restart" {
t.Errorf("capability name: got %q want restart", cap.Name)
}
if cap.Platform != "qemu" {
t.Errorf("platform: got %q want qemu", cap.Platform)
}
if cap.MinimumApprovalLevel != unified.ApprovalAdmin {
t.Errorf("approval level: got %q want admin", cap.MinimumApprovalLevel)
}
if len(cap.Params) != 2 {
t.Fatalf("params: got %d want 2", len(cap.Params))
}
// Param schemas must round-trip fully — this is the whole point of
// the new endpoint relative to the prose summary in get_resource_context.
force := cap.Params[0]
if force.Name != "force" || force.Type != "boolean" || force.Required {
t.Errorf("force param = %+v, want {force boolean false}", force)
}
if force.Description == "" {
t.Errorf("force param description must be preserved, got empty")
}
timeout := cap.Params[1]
if !timeout.Required || len(timeout.Enum) != 3 || timeout.Pattern == "" {
t.Errorf("timeout param = %+v, want required with enum+pattern", timeout)
}
// Internal execution plumbing must never leak to the wire.
if raw := strings.TrimSpace(rec.Body.String()); strings.Contains(raw, "shouldNotAppearOnWire") {
t.Errorf("internal handler leaked to wire: %s", raw)
}
}
func TestHandleAgentResourceCapabilities_EmptyCapabilitiesReturns200(t *testing.T) {
// A resource advertising nothing is the common case across the fleet
// and is exactly the signal an agent needs before attempting
// plan_action. It must be 200 with a JSON array, never null.
h, _ := agentContextFixtureHandlers(t, "vm:101")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
h.HandleResourceCapabilities(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
}
var bundle AgentResourceCapabilities
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
t.Fatalf("unmarshal bundle: %v", err)
}
if bundle.Capabilities == nil {
t.Fatal("capabilities is null; want non-nil empty array on the wire")
}
if len(bundle.Capabilities) != 0 {
t.Errorf("capabilities: got %d want 0", len(bundle.Capabilities))
}
// Pin the wire shape directly: capabilities must serialize as [].
if !strings.Contains(rec.Body.String(), `"capabilities":[]`) {
t.Errorf("wire body must contain capabilities:[]; got %s", rec.Body.String())
}
}
func TestHandleAgentResourceCapabilities_404OnUnknownResource(t *testing.T) {
h, _ := agentContextFixtureHandlers(t, "vm:101")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:nonexistent", nil)
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
h.HandleResourceCapabilities(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404; got %d", rec.Code)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal error body: %v", err)
}
if body["error"] != agentcapabilities.AgentErrCodeResourceNotFound {
t.Errorf("expected error=%s; got %v", agentcapabilities.AgentErrCodeResourceNotFound, body)
}
}
func TestHandleAgentResourceCapabilities_RejectsNonGet(t *testing.T) {
h, _ := agentContextFixtureHandlers(t, "vm:101")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/agent/resource-capabilities/vm:101", nil)
h.HandleResourceCapabilities(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405; got %d", rec.Code)
}
}
func TestHandleAgentResourceContext_RecentActionsCarryRefusalTokens(t *testing.T) {
// Refused dispatches (resource_remediation_locked:, plan_drift:)
// must surface verbatim in the recent-actions slice so agents can
@ -2605,3 +2755,20 @@ func TestExtractAgentResourceContextID(t *testing.T) {
}
}
}
func TestExtractAgentResourceCapabilitiesID(t *testing.T) {
cases := []struct {
path string
want string
}{
{"/api/agent/resource-capabilities/vm:101", "vm:101"},
{"/api/agent/resource-capabilities/vm:101/", "vm:101"},
{"/api/agent/resource-capabilities/instance:node:200", "instance:node:200"},
{"/api/agent/resource-capabilities/", ""},
}
for _, tc := range cases {
if got := extractAgentResourceCapabilitiesID(tc.path); got != tc.want {
t.Errorf("extractAgentResourceCapabilitiesID(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}

View file

@ -18628,6 +18628,54 @@ func TestContract_GetResourceContextCapabilityListsPendingApprovals(t *testing.T
}
}
// TestContract_ListResourceCapabilitiesCapabilitySurfacesStructuredParams pins
// the canonical agent-surface contract for action planning: agents must be able
// to discover a resource's governed capabilities and their parameter schemas
// through a dedicated structured tool, not only as the count-limited prose
// summary inside the resource-context bundle (formatCapabilityFact omits
// parameter schemas). Without this tool, agents guess plan_action inputs.
func TestContract_ListResourceCapabilitiesCapabilitySurfacesStructuredParams(t *testing.T) {
manifest := agentcapabilities.CanonicalManifest()
cap, ok := agentcapabilities.FindCapability(manifest.Capabilities, agentcapabilities.ListResourceCapabilitiesCapabilityName)
if !ok {
t.Fatalf("manifest missing %s", agentcapabilities.ListResourceCapabilitiesCapabilityName)
}
if cap.Method != http.MethodGet || cap.Scope != config.ScopeMonitoringRead {
t.Fatalf("%s must be a GET under monitoring:read so any read-only agent can plan actions; got method=%s scope=%s",
agentcapabilities.ListResourceCapabilitiesCapabilityName, cap.Method, cap.Scope)
}
if cap.Path != agentcapabilities.ListResourceCapabilitiesCapabilityPath {
t.Fatalf("%s path = %q, want %q", agentcapabilities.ListResourceCapabilitiesCapabilityName, cap.Path, agentcapabilities.ListResourceCapabilitiesCapabilityPath)
}
if len(cap.ErrorCodes) == 0 || cap.ErrorCodes[0] != agentcapabilities.AgentErrCodeResourceNotFound {
t.Fatalf("%s must declare resource_not_found so agents branch cleanly on unknown ids", agentcapabilities.ListResourceCapabilitiesCapabilityName)
}
// The capability must be projected to the MCP surface; otherwise external
// agents (the primary consumers of plan_action) cannot reach it.
found := false
for _, name := range agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP) {
if name.Name == agentcapabilities.ListResourceCapabilitiesCapabilityName {
found = true
break
}
}
if !found {
t.Fatalf("%s must be projected to the Pulse MCP surface", agentcapabilities.ListResourceCapabilitiesCapabilityName)
}
// The handler must return a non-nil capabilities array (never null) so an
// agent can branch on len() without nil-checking — the same wire-shape rule
// the resource-context and fleet-context bundles follow.
handlerBytes, err := os.ReadFile("agent_resource_context.go")
if err != nil {
t.Fatalf("read agent_resource_context.go: %v", err)
}
handlerSrc := string(handlerBytes)
if !strings.Contains(handlerSrc, "capabilities := []unified.ResourceCapability{}") {
t.Errorf("%s handler must initialize capabilities as a non-nil empty array so the wire shape is always []", agentcapabilities.ListResourceCapabilitiesCapabilityName)
}
}
func TestContract_AgentCommandPayloadsRequireActionScope(t *testing.T) {
source, err := os.ReadFile("agent_command_redaction.go")
if err != nil {

View file

@ -424,6 +424,7 @@ var allRouteAllowlist = []string{
"/api/maintenance-verifications/{reportId}",
"POST /api/maintenance-verifications/{reportId}/review",
"/api/agent/resource-context/{id}",
"/api/agent/resource-capabilities/{id}",
"/api/agent/fleet-context",
"/api/agent/capabilities",
"/api/agent/events",

View file

@ -89,6 +89,15 @@ func (r *Router) registerMonitoringResourceRoutes(
agentcapabilities.ResourceContextCapabilityName,
r.agentContextHandler.HandleResourceContext,
))))
// Agent-consumable structured capability surface for a single resource.
// Companion to the resource-context bundle above: that renders capabilities
// as count-limited prose facts; this returns the structured list (names +
// parameter schemas) an agent needs to populate plan_action.capabilityName
// and params without guessing.
r.mux.HandleFunc("/api/agent/resource-capabilities/{id}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity(
agentcapabilities.ListResourceCapabilitiesCapabilityName,
r.agentContextHandler.HandleResourceCapabilities,
))))
// Agent-consumable fleet triage view — thin per-resource rollups
// across the org so an agent can pick a focus in one read instead
// of walking every resource id and bundling each. Companion to the