GeezerVison is showing github.com — fix[backend](soar/mcp): make soar.rule.update partial (merge + delete… · utmstack/UTMStack@1c69e9b
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: backend/modules/mcp/catalog.json +9-8Lines changed: 9 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 319 | 319 | {"name": "incident_history.count", "status": "implemented", "module": "incidents", "title": "Count incident history rows", "permission": "incidents.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"incident_id": {"type": "integer"}}}, "output": "{ count: int64 }", "maps_to": "incidents.IncidentHistoryUsecase.Count", "rest_equivalent": "GET /api/v1/incident-histories/count"}, | 320 | 320 | {"name": "incident_history.get", "status": "implemented", "module": "incidents", "title": "Get incident history row", "permission": "incidents.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}, "output": "HistoryResponse", "maps_to": "incidents.IncidentHistoryUsecase.GetByID", "rest_equivalent": "GET /api/v1/incident-histories/:id"}, | 321 | 321 | {"name": "incidents.assignees", "status": "implemented", "module": "incidents", "title": "List the assignees incidents are currently handed to", "permission": "incidents.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {}}, "output": "string[]", "maps_to": "incidents.IncidentUsecase.GetAssignees", "rest_equivalent": "GET /api/v1/incidents/assignees"}, | 322 | | - {"name": "soar.flow.create", "status": "implemented", "module": "soar", "title": "Create SOAR rule", "permission": "soar.write", "input": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "conditions": {"type": "array", "items": {"$ref": "#/definitions/FilterVM"}, "description": "Alert trigger: ALL must match (AND)"}, "roots": {"type": "array", "items": {"type": "string"}, "description": "Node ids starting at depth 0"}, "nodes": {"type": "object", "additionalProperties": {"$ref": "#/definitions/FlowNodeVM"}, "description": "DAG nodes keyed by id — see mcp://utmstack/docs/soar-flow-guide"}, "max_depth": {"type": "integer"}, "active": {"type": "boolean"}}, "required": ["name", "conditions", "roots", "nodes", "active"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Create", "rest_equivalent": "POST /api/v1/soar/rules"}, | 323 | | - {"name": "soar.flow.update", "status": "implemented", "module": "soar", "title": "Update SOAR rule", "permission": "soar.write", "input": {"type": "object", "properties": {"rel_path": {"type": "string"}, "name": {"type": "string"}, "conditions": {"type": "array", "items": {"$ref": "#/definitions/FilterVM"}}, "roots": {"type": "array", "items": {"type": "string"}}, "nodes": {"type": "object", "additionalProperties": {"$ref": "#/definitions/FlowNodeVM"}}, "max_depth": {"type": "integer"}, "active": {"type": "boolean"}}, "required": ["rel_path", "name", "conditions", "roots", "nodes", "active"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Update", "rest_equivalent": "PUT /api/v1/soar/rules/:relPath"}, | 324 | | - {"name": "soar.flow.get", "status": "implemented", "module": "soar", "title": "Get SOAR rule", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Get", "rest_equivalent": "GET /api/v1/soar/rules/:relPath"}, | 325 | | - {"name": "soar.flow.delete", "status": "implemented", "module": "soar", "title": "Delete SOAR rule", "permission": "soar.write", "annotations": {"destructiveHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]}, "output": "{ rel_path: string, deleted: true }", "maps_to": "soar.RuleUsecase.Delete", "rest_equivalent": "DELETE /api/v1/soar/rules/:relPath"}, | 326 | | - {"name": "soar.flow.set_enabled", "status": "implemented", "module": "soar", "title": "Enable or disable SOAR rule", "permission": "soar.write", "annotations": {"idempotentHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}, "enabled": {"type": "boolean"}}, "required": ["rel_path", "enabled"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.SetEnabled", "rest_equivalent": "PUT /api/v1/soar/rules/:relPath/enabled"}, | 327 | | - {"name": "soar.flow.list", "status": "implemented", "module": "soar", "title": "List SOAR rules", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"name": {"type": "string"}, "enabled": {"type": "boolean"}, "template": {"type": "string"}, "page": {"type": "integer"}, "size": {"type": "integer"}}}, "output": "{ items: RuleResponse[], total: int64 }", "maps_to": "soar.RuleUsecase.List", "rest_equivalent": "GET /api/v1/soar/rules"}, | | 322 | + {"name": "soar.rule.create", "status": "implemented", "module": "soar", "title": "Create SOAR rule", "permission": "soar.write", "description": "Creates a NEW flow; the full flow (conditions, roots, nodes) is required. To modify an existing flow use soar.rule.update (partial) instead.", "input": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "conditions": {"type": "array", "items": {"$ref": "#/definitions/FilterVM"}, "description": "Alert trigger: ALL must match (AND)"}, "roots": {"type": "array", "items": {"type": "string"}, "description": "Node ids starting at depth 0"}, "nodes": {"type": "object", "additionalProperties": {"$ref": "#/definitions/FlowNodeVM"}, "description": "DAG nodes keyed by id — see mcp://utmstack/docs/soar-flow-guide"}, "max_depth": {"type": "integer"}, "active": {"type": "boolean"}}, "required": ["name", "conditions", "roots", "nodes", "active"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Create", "rest_equivalent": "POST /api/v1/soar/rules"}, | | 323 | + {"name": "soar.rule.update", "status": "implemented", "module": "soar", "title": "Update SOAR rule", "permission": "soar.write", "description": "PARTIAL update: send only what changes. Omitted fields keep their current value; nodes are merged by id; deleted_nodes removes nodes and scrubs their edges. Read the flow with soar.rule.get first, then send just the changed pieces. Never resend the whole flow.", "input": {"type": "object", "properties": {"rel_path": {"type": "string"}, "name": {"type": "string", "description": "omit to keep current"}, "description": {"type": "string", "description": "omit to keep current"}, "conditions": {"type": "array", "items": {"$ref": "#/definitions/FilterVM"}, "description": "full replace of the list; omit to keep current"}, "roots": {"type": "array", "items": {"type": "string"}, "description": "omit to keep current (deleted ids always pruned)"}, "nodes": {"type": "object", "additionalProperties": {"$ref": "#/definitions/FlowNodeVM"}, "description": "nodes to add or overwrite, merged over current — not a replacement"}, "deleted_nodes": {"type": "array", "items": {"type": "string"}, "description": "ids to remove; edges scrubbed automatically; must not overlap nodes"}, "max_depth": {"type": "integer", "description": "omit to keep current"}, "active": {"type": "boolean", "description": "omit to keep current (or use soar.rule.set_enabled)"}}, "required": ["rel_path"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Update (merged at MCP layer)", "rest_equivalent": "PUT /api/v1/soar/rules/:relPath"}, | | 324 | + {"name": "soar.rule.get", "status": "implemented", "module": "soar", "title": "Get SOAR rule", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.Get", "rest_equivalent": "GET /api/v1/soar/rules/:relPath"}, | | 325 | + {"name": "soar.rule.delete", "status": "implemented", "module": "soar", "title": "Delete SOAR rule", "permission": "soar.write", "annotations": {"destructiveHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}}, "required": ["rel_path"]}, "output": "{ rel_path: string, deleted: true }", "maps_to": "soar.RuleUsecase.Delete", "rest_equivalent": "DELETE /api/v1/soar/rules/:relPath"}, | | 326 | + {"name": "soar.rule.set_enabled", "status": "implemented", "module": "soar", "title": "Enable or disable SOAR rule", "permission": "soar.write", "annotations": {"idempotentHint": true}, "input": {"type": "object", "properties": {"rel_path": {"type": "string"}, "enabled": {"type": "boolean"}}, "required": ["rel_path", "enabled"]}, "output": "RuleResponse", "maps_to": "soar.RuleUsecase.SetEnabled", "rest_equivalent": "PUT /api/v1/soar/rules/:relPath/enabled"}, | | 327 | + {"name": "soar.rule.list", "status": "implemented", "module": "soar", "title": "List SOAR rules", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"name": {"type": "string"}, "enabled": {"type": "boolean"}, "template": {"type": "string"}, "page": {"type": "integer"}, "size": {"type": "integer"}}}, "output": "{ items: RuleResponse[], total: int64 }", "maps_to": "soar.RuleUsecase.List", "rest_equivalent": "GET /api/v1/soar/rules"}, | 328 | 328 | {"name": "soar.node_types", "status": "implemented", "module": "soar", "title": "List SOAR flow node types", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {}}, "output": "{ kinds: string[], executor_types: string[] } — kinds is fixed (executor, enrichment); executor_types is the live registry on this instance (shell/http/conditional always; llm_enrich/llm_action, notify, incident, mail when configured)", "maps_to": "soar.Module.GetExecutorTypes"}, | 329 | 329 | {"name": "soar.rule.resolve_filter_values", "status": "implemented", "module": "soar", "title": "Suggest filter values", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"field": {"type": "string"}, "prefix": {"type": "string"}}, "required": ["field"]}, "output": "ResolveFilterValuesResponse", "maps_to": "soar.RuleUsecase.ResolveFilterValues", "rest_equivalent": "GET /api/v1/soar/rules/resolve-filter-values"}, | 330 | 330 | {"name": "soar.execution.list", "status": "implemented", "module": "soar", "title": "List rule executions", "permission": "soar.read", "annotations": {"readOnlyHint": true}, "input": {"type": "object", "properties": {"rule_rel_path": {"type": "string"}, "status": {"type": "string"}, "from": {"type": "string"}, "to": {"type": "string"}, "page": {"type": "integer"}, "size": {"type": "integer"}}}, "output": "{ items: ExecutionResponse[], total: int64 }", "maps_to": "soar.ExecutionUsecase.List", "rest_equivalent": "GET /api/v1/soar/rule-executions"}, | | 586 | 586 | { | 587 | 587 | "name": "soc.draft-soar-rule", | 588 | 588 | "title": "Draft SOAR rule", | 589 | | - "description": "Guides the model through SOAR flow construction: list existing flows to start from, list variables, propose filter conditions, then create the flow on confirmation.", | | 589 | + "description": "Guides the model through SOAR flow construction: list existing flows to start from, read the flow if modifying it, list variables, propose filter conditions, then apply on confirmation — create for new flows, PARTIAL soar.rule.update (delta only) for existing ones.", | 590 | 590 | "status": "implemented", | 591 | 591 | "arguments": [ | 592 | 592 | {"name": "goal", "description": "Plain-language goal for the rule (e.g. 'isolate hosts on Mimikatz detections')", "required": true} | 593 | 593 | ], | 594 | 594 | "tool_sequence": [ | 595 | 595 | "soar.rule.list", | | 596 | + "soar.rule.get", | 596 | 597 | "soar.variable.list", | 597 | 598 | "soar.rule.resolve_filter_values", | 598 | 599 | "(model drafts)", | 599 | | - "soar.rule.create" | | 600 | + "soar.rule.create | soar.rule.update (partial)" | 600 | 601 | ] | 601 | 602 | } | 602 | 603 | ], | |
Collapse file Copy file name to clipboardExpand all lines: backend/modules/mcp/docs_soar.go +25-17Lines changed: 25 additions & 17 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -6,9 +6,10 @@ package mcp | 6 | 6 | const soarFlowGuideDoc = `# SOAR flow guide — building alert-response DAGs | 7 | 7 |
| 8 | 8 | A SOAR rule ("flow") is a DAG of nodes that runs when an alert matches its | 9 | | -trigger conditions. Build flows with soar.rule.create / soar.rule.update: | 10 | | -pass conditions + roots + nodes in one call. Read an existing flow first | 11 | | -with soar.rule.get (rel_path from soar.rule.list). | | 9 | +trigger conditions. Create NEW flows with soar.rule.create (full flow in one | | 10 | +call). MODIFY existing flows with soar.rule.update — it is PARTIAL: send only | | 11 | +the fields that change, never resend the whole flow (see "Editing an existing | | 12 | +flow" below). | 12 | 13 |
| 13 | 14 | ## Node kinds — executor vs enrichment | 14 | 15 |
| @@ -93,20 +94,27 @@ and "failure path" subtrees. | 93 | 94 |
| 94 | 95 | ## Editing an existing flow | 95 | 96 |
| 96 | | -soar.rule.update is FULL REPLACE, not a patch — it overwrites conditions, roots | 97 | | -and the entire nodes map. Always: soar.rule.get(rel_path) → modify the complete | 98 | | -payload in memory → send everything back. | 99 | | -
| 100 | | -1. **Add a node** — new id in nodes; wire it by appending that id to an | 101 | | - existing node's onSuccess or onError (or to roots if it starts at depth 0). | 102 | | -2. **Change a node** — edit its entry in place; keep its id stable (other | 103 | | - nodes' edges and $(<nodeId>...) interpolations reference it). | 104 | | -3. **Remove/replace a node** — delete its entry AND scrub its id from every | 105 | | - other node's onSuccess/onError and from roots. To replace, keep the id and | 106 | | - swap kind/executor/params, or delete + add under a new id and rewire edges. | 107 | | - The server does NOT validate edge consistency at write time — dangling refs | 108 | | - only surface at run time as dead branches, so you own referential integrity. | 109 | | -4. **Enable/disable** — soar.rule.set_enabled(rel_path, enabled); no payload | | 97 | +soar.rule.update is a PARTIAL update, not a full replace: | | 98 | +- omitted fields keep their current value (name, description, conditions, | | 99 | + roots, nodes, max_depth, active); | | 100 | +- nodes is a MERGE by id: the ids you send are added or overwritten, every | | 101 | + other node stays as-is; | | 102 | +- deleted_nodes removes ids, and the server scrubs each id from every | | 103 | + remaining node's onSuccess/onError and from roots. Never list the same id | | 104 | + in both nodes and deleted_nodes. | | 105 | +
| | 106 | +So: soar.rule.get(rel_path) once, then send only the delta. Typical edits: | | 107 | +
| | 108 | +1. **Add a node** — send nodes: {"new_id": {...}}; if it wires off an | | 109 | + existing node, also send that existing node with new_id appended to its | | 110 | + onSuccess or onError. A new root: send roots: [current roots + new_id]. | | 111 | +2. **Change a node** — send nodes: {"same_id": {updated node}}. Keep the id | | 112 | + stable: other nodes' edges and $(<nodeId>...) interpolations reference it. | | 113 | +3. **Remove a node** — send deleted_nodes: ["id"]. Edge scrubbing is done | | 114 | + for you; nothing else to send. | | 115 | +4. **Change triggers** — conditions is a full replace of the small list, so | | 116 | + send the complete new conditions array. | | 117 | +5. **Enable/disable** — soar.rule.set_enabled(rel_path, enabled); no payload | 110 | 118 | needed. System-owned flows are read-only (update/delete rejected). | 111 | 119 |
| 112 | 120 | ## Worked example — block brute-force source IP unless internal | |
|