Skip to content

Commit 2cf35b8

Browse files
JAORMXclaude
andcommitted
Add forEach step type for VMCP composite tool workflows
Workflows today require statically defined steps -- you cannot dynamically spawn steps based on a previous step's output. This makes patterns like "for each package in the image, query for vulnerabilities" impossible without hardcoding every item. Add a third step type `forEach` that iterates over a collection produced by a previous step and executes an inner tool step for each item, with configurable parallelism and error handling. Key design decisions: - forEach is a DAG-opaque macro: the DAG sees one node, internal parallelism is self-managed via errgroup + semaphore - Single inner step (tool type only) keeps it simple; multi-step iteration bodies and elicitation inner steps can come later - Nested forEach is explicitly rejected at validation time - Collection resolves via Go template expansion then JSON parse, reusing existing template infrastructure - Aggregated output has a well-known shape (iterations, count, completed, failed) so downstream templates have a predictable structure to reference Safety limits: - maxIterations: default 100, hard cap 1000 - maxParallel: default 10 (DAG default), hard cap 50 - itemVar cannot shadow the reserved "index" key - Step and workflow timeouts apply as normal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 60d91c4 commit 2cf35b8

19 files changed

Lines changed: 1562 additions & 28 deletions

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ spec:
174174
Note: the templating is only supported on the first level of the key-value pairs.
175175
type: object
176176
x-kubernetes-preserve-unknown-fields: true
177+
collection:
178+
description: |-
179+
Collection is a Go template expression that resolves to a JSON array or a slice.
180+
Only used when Type is "forEach".
181+
type: string
177182
condition:
178183
description: Condition is a template expression that determines
179184
if the step should execute
@@ -194,6 +199,24 @@ spec:
194199
id:
195200
description: ID is the unique identifier for this step.
196201
type: string
202+
itemVar:
203+
description: |-
204+
ItemVar is the variable name used to reference the current item in forEach templates.
205+
Defaults to "item" if not specified.
206+
Only used when Type is "forEach".
207+
type: string
208+
maxIterations:
209+
description: |-
210+
MaxIterations limits the number of items that can be iterated over.
211+
Defaults to 100, hard cap at 1000.
212+
Only used when Type is "forEach".
213+
type: integer
214+
maxParallel:
215+
description: |-
216+
MaxParallel limits the number of concurrent iterations in a forEach step.
217+
Defaults to the DAG executor's maxParallel (10).
218+
Only used when Type is "forEach".
219+
type: integer
197220
message:
198221
description: |-
199222
Message is the elicitation message
@@ -263,6 +286,12 @@ spec:
263286
elicitation
264287
type: object
265288
x-kubernetes-preserve-unknown-fields: true
289+
step:
290+
description: |-
291+
InnerStep defines the step to execute for each item in the collection.
292+
Only used when Type is "forEach". Only tool-type inner steps are supported.
293+
type: object
294+
x-kubernetes-preserve-unknown-fields: true
266295
timeout:
267296
description: Timeout is the maximum execution time for this
268297
step
@@ -279,6 +308,7 @@ spec:
279308
enum:
280309
- tool
281310
- elicitation
311+
- forEach
282312
type: string
283313
required:
284314
- id

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,11 @@ spec:
975975
Note: the templating is only supported on the first level of the key-value pairs.
976976
type: object
977977
x-kubernetes-preserve-unknown-fields: true
978+
collection:
979+
description: |-
980+
Collection is a Go template expression that resolves to a JSON array or a slice.
981+
Only used when Type is "forEach".
982+
type: string
978983
condition:
979984
description: Condition is a template expression that
980985
determines if the step should execute
@@ -996,6 +1001,24 @@ spec:
9961001
description: ID is the unique identifier for this
9971002
step.
9981003
type: string
1004+
itemVar:
1005+
description: |-
1006+
ItemVar is the variable name used to reference the current item in forEach templates.
1007+
Defaults to "item" if not specified.
1008+
Only used when Type is "forEach".
1009+
type: string
1010+
maxIterations:
1011+
description: |-
1012+
MaxIterations limits the number of items that can be iterated over.
1013+
Defaults to 100, hard cap at 1000.
1014+
Only used when Type is "forEach".
1015+
type: integer
1016+
maxParallel:
1017+
description: |-
1018+
MaxParallel limits the number of concurrent iterations in a forEach step.
1019+
Defaults to the DAG executor's maxParallel (10).
1020+
Only used when Type is "forEach".
1021+
type: integer
9991022
message:
10001023
description: |-
10011024
Message is the elicitation message
@@ -1066,6 +1089,12 @@ spec:
10661089
schema for elicitation
10671090
type: object
10681091
x-kubernetes-preserve-unknown-fields: true
1092+
step:
1093+
description: |-
1094+
InnerStep defines the step to execute for each item in the collection.
1095+
Only used when Type is "forEach". Only tool-type inner steps are supported.
1096+
type: object
1097+
x-kubernetes-preserve-unknown-fields: true
10691098
timeout:
10701099
description: Timeout is the maximum execution time
10711100
for this step
@@ -1083,6 +1112,7 @@ spec:
10831112
enum:
10841113
- tool
10851114
- elicitation
1115+
- forEach
10861116
type: string
10871117
required:
10881118
- id

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpcompositetooldefinitions.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ spec:
177177
Note: the templating is only supported on the first level of the key-value pairs.
178178
type: object
179179
x-kubernetes-preserve-unknown-fields: true
180+
collection:
181+
description: |-
182+
Collection is a Go template expression that resolves to a JSON array or a slice.
183+
Only used when Type is "forEach".
184+
type: string
180185
condition:
181186
description: Condition is a template expression that determines
182187
if the step should execute
@@ -197,6 +202,24 @@ spec:
197202
id:
198203
description: ID is the unique identifier for this step.
199204
type: string
205+
itemVar:
206+
description: |-
207+
ItemVar is the variable name used to reference the current item in forEach templates.
208+
Defaults to "item" if not specified.
209+
Only used when Type is "forEach".
210+
type: string
211+
maxIterations:
212+
description: |-
213+
MaxIterations limits the number of items that can be iterated over.
214+
Defaults to 100, hard cap at 1000.
215+
Only used when Type is "forEach".
216+
type: integer
217+
maxParallel:
218+
description: |-
219+
MaxParallel limits the number of concurrent iterations in a forEach step.
220+
Defaults to the DAG executor's maxParallel (10).
221+
Only used when Type is "forEach".
222+
type: integer
200223
message:
201224
description: |-
202225
Message is the elicitation message
@@ -266,6 +289,12 @@ spec:
266289
elicitation
267290
type: object
268291
x-kubernetes-preserve-unknown-fields: true
292+
step:
293+
description: |-
294+
InnerStep defines the step to execute for each item in the collection.
295+
Only used when Type is "forEach". Only tool-type inner steps are supported.
296+
type: object
297+
x-kubernetes-preserve-unknown-fields: true
269298
timeout:
270299
description: Timeout is the maximum execution time for this
271300
step
@@ -282,6 +311,7 @@ spec:
282311
enum:
283312
- tool
284313
- elicitation
314+
- forEach
285315
type: string
286316
required:
287317
- id

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,11 @@ spec:
978978
Note: the templating is only supported on the first level of the key-value pairs.
979979
type: object
980980
x-kubernetes-preserve-unknown-fields: true
981+
collection:
982+
description: |-
983+
Collection is a Go template expression that resolves to a JSON array or a slice.
984+
Only used when Type is "forEach".
985+
type: string
981986
condition:
982987
description: Condition is a template expression that
983988
determines if the step should execute
@@ -999,6 +1004,24 @@ spec:
9991004
description: ID is the unique identifier for this
10001005
step.
10011006
type: string
1007+
itemVar:
1008+
description: |-
1009+
ItemVar is the variable name used to reference the current item in forEach templates.
1010+
Defaults to "item" if not specified.
1011+
Only used when Type is "forEach".
1012+
type: string
1013+
maxIterations:
1014+
description: |-
1015+
MaxIterations limits the number of items that can be iterated over.
1016+
Defaults to 100, hard cap at 1000.
1017+
Only used when Type is "forEach".
1018+
type: integer
1019+
maxParallel:
1020+
description: |-
1021+
MaxParallel limits the number of concurrent iterations in a forEach step.
1022+
Defaults to the DAG executor's maxParallel (10).
1023+
Only used when Type is "forEach".
1024+
type: integer
10021025
message:
10031026
description: |-
10041027
Message is the elicitation message
@@ -1069,6 +1092,12 @@ spec:
10691092
schema for elicitation
10701093
type: object
10711094
x-kubernetes-preserve-unknown-fields: true
1095+
step:
1096+
description: |-
1097+
InnerStep defines the step to execute for each item in the collection.
1098+
Only used when Type is "forEach". Only tool-type inner steps are supported.
1099+
type: object
1100+
x-kubernetes-preserve-unknown-fields: true
10721101
timeout:
10731102
description: Timeout is the maximum execution time
10741103
for this step
@@ -1086,6 +1115,7 @@ spec:
10861115
enum:
10871116
- tool
10881117
- elicitation
1118+
- forEach
10891119
type: string
10901120
required:
10911121
- id

docs/arch/10-virtual-mcp-architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ graph LR
157157

158158
Step dependencies form a DAG (Directed Acyclic Graph). Steps without dependencies execute in parallel, while dependent steps wait for prerequisites.
159159

160+
Steps can be of three types:
161+
- **tool**: Execute a backend tool
162+
- **elicitation**: Request user input via MCP elicitation protocol
163+
- **forEach**: Iterate over a collection from a previous step, executing an inner tool step per item with bounded parallelism
164+
160165
**Implementation**: `pkg/vmcp/composer/`
161166

162167
## Two-Boundary Authentication

docs/operator/advanced-workflow-patterns.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This guide covers advanced workflow patterns and best practices for Virtual MCP
1313
- [Performance Optimization](#performance-optimization)
1414
- [Best Practices](#best-practices)
1515
- [Common Patterns](#common-patterns)
16+
- [ForEach Iteration Patterns](#foreach-iteration-patterns)
1617

1718
---
1819

@@ -891,6 +892,75 @@ steps:
891892

892893
---
893894

895+
## ForEach Iteration Patterns
896+
897+
The `forEach` step type iterates over a collection produced by a previous step, executing an inner tool step for each item. The forEach step is a single node in the DAG -- its internal parallelism is self-managed.
898+
899+
### Basic forEach: Vulnerability Scanning
900+
901+
```yaml
902+
steps:
903+
- id: get_packages
904+
type: tool
905+
tool: oci-registry.get_image_config
906+
arguments:
907+
image_ref: "{{.params.image}}"
908+
909+
- id: check_each_vuln
910+
type: forEach
911+
collection: "{{json .steps.get_packages.output.packages}}"
912+
itemVar: pkg
913+
maxParallel: 5
914+
step:
915+
type: tool
916+
tool: osv.query_vulnerability
917+
arguments:
918+
package_name: "{{.forEach.pkg.name}}"
919+
ecosystem: "{{.forEach.pkg.ecosystem}}"
920+
version: "{{.forEach.pkg.version}}"
921+
dependsOn: [get_packages]
922+
onError:
923+
action: continue # Skip failed items, don't abort
924+
925+
- id: summarize
926+
type: tool
927+
tool: reporter.summarize
928+
arguments:
929+
total: "{{.steps.check_each_vuln.output.count}}"
930+
failed: "{{.steps.check_each_vuln.output.failed}}"
931+
results: "{{json .steps.check_each_vuln.output.iterations}}"
932+
dependsOn: [check_each_vuln]
933+
```
934+
935+
### forEach with Error Abort
936+
937+
When any iteration fails, abort immediately and fail the workflow:
938+
939+
```yaml
940+
- id: deploy_each
941+
type: forEach
942+
collection: "{{json .steps.get_targets.output.targets}}"
943+
itemVar: target
944+
maxParallel: 1 # Sequential deployment
945+
step:
946+
type: tool
947+
tool: kubectl.apply
948+
arguments:
949+
cluster: "{{.forEach.target.cluster}}"
950+
manifest: "{{.params.manifest}}"
951+
dependsOn: [get_targets]
952+
# Default onError is abort -- any failure stops remaining iterations
953+
```
954+
955+
### forEach Limits and Safety
956+
957+
| Setting | Default | Hard Cap | Description |
958+
|---------|---------|----------|-------------|
959+
| `maxIterations` | 100 | 1000 | Max collection items |
960+
| `maxParallel` | 10 (DAG default) | 50 | Concurrent iterations |
961+
962+
The forEach step's timeout (inherited from step-level `timeout`) applies to the entire iteration set.
963+
894964
## Additional Resources
895965

896966
- [VirtualMCPCompositeToolDefinition Guide](virtualmcpcompositetooldefinition-guide.md) - Basic workflow concepts

docs/operator/composite-tools-quick-reference.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ spec:
2424

2525
steps: # Required: workflow steps
2626
- id: step1
27-
type: tool # tool|elicitation
27+
type: tool # tool|elicitation|forEach
2828
tool: workload.tool_name
2929
arguments:
3030
key: "{{.params.param_name}}"
@@ -228,6 +228,33 @@ steps:
228228
dependsOn: [process_a, process_b]
229229
```
230230

231+
### ForEach Iteration
232+
233+
```yaml
234+
steps:
235+
- id: get_packages
236+
type: tool
237+
tool: oci.get_image_config
238+
arguments:
239+
image: "{{.params.image}}"
240+
241+
- id: check_vulns
242+
type: forEach
243+
collection: "{{json .steps.get_packages.output.packages}}"
244+
itemVar: pkg # defaults to "item"
245+
maxParallel: 5 # defaults to DAG maxParallel (10)
246+
step: # single inner step (tool only)
247+
type: tool
248+
tool: osv.query_vulnerability
249+
arguments:
250+
package_name: "{{.forEach.pkg.name}}"
251+
dependsOn: [get_packages]
252+
onError:
253+
action: continue # skip failed items, don't abort
254+
```
255+
256+
**Output**: `{{.steps.check_vulns.output.iterations}}`, `.count`, `.completed`, `.failed`
257+
231258
### Retry with Fallback
232259

233260
```yaml
@@ -253,6 +280,10 @@ steps:
253280
- ✅ Tool format: `workload_id.tool_name`
254281
- ✅ Max retry count: 10 (runtime capped - values > 10 are silently reduced with warning)
255282
- ✅ Max workflow steps: 100 (runtime enforced - workflows > 100 steps fail validation)
283+
- ✅ forEach maxIterations: 1000 (hard cap), defaults to 100
284+
- ✅ forEach maxParallel: 50 (hard cap), defaults to DAG maxParallel (10)
285+
- ✅ forEach inner step must be type `tool` (no nested forEach or elicitation)
286+
- ✅ forEach `itemVar` cannot be `index` (reserved)
256287

257288
**Note**: Max retry and max steps limits are currently enforced at runtime. Future work may add CRD-level validation (`+kubebuilder:validation:MaxItems=100`) and webhook validation to fail at submission time rather than execution time.
258289

0 commit comments

Comments
 (0)