Quick reference for Virtual MCP Composite Tool workflows.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: my-workflow
namespace: default
spec:
name: my_workflow_name # Tool name exposed to clients
description: What it does # Required description
timeout: 30m # Optional: workflow timeout (default: 30m)
failureMode: abort # Optional: abort|continue (default: abort)
parameters: # Optional: input parameters
param_name:
type: string
description: Description of the parameter
required: false
annotations: # Optional: explicit MCP tool annotations
title: My Workflow # Derivation runs at advertise time, not CRD
# admission. The floor is fail-closed: with
# one or more tool steps it is always non-nil,
# and any step with nil/unknown annotations
# taints it to readOnly=false,
# destructive=true, openWorld=true.
readOnlyHint: false # ⚠ An explicit readOnlyHint: true here would
# CONTRADICT the floor whenever a step tool
# does not declare readOnlyHint: true (the
# common case), dropping the composite tool
# from tools/list at runtime with a warning.
# thv vmcp validate does NOT catch this — it
# surfaces only in the vMCP logs. Prefer
# leaving readOnlyHint unset (derive it) or
# declaring a value no safer than the floor.
steps: # Required: workflow steps
- id: step1
type: tool # tool|elicitation|forEach
tool: workload.tool_name
arguments:
key: "{{.params.param_name}}"# Independent steps run in parallel automatically
steps:
- id: fetch_a # Level 1: Runs in parallel ─┐
- id: fetch_b # Level 1: Runs in parallel ─┼─> aggregate
- id: fetch_c # Level 1: Runs in parallel ─┘
- id: aggregate # Level 2: Waits for Level 1
dependsOn: [fetch_a, fetch_b, fetch_c]steps:
- id: step1
- id: step2
dependsOn: [step1] # Runs after step1 completes
- id: step3
dependsOn: [step1, step2] # Waits for both step1 AND step2Workflows use Go's text/template syntax with additional context variables and functions.
# Access input parameters
"{{.params.parameter_name}}"
# Access step outputs
"{{.steps.step_id.output}}"
"{{.steps.step_id.output.field_name}}"
"{{.steps.step_id.status}}" # completed|failed|skipped|running
# Access workflow-scoped variables
"{{.vars.variable_name}}"
# Access step errors
"{{.steps.step_id.error}}"Composite Tools supports all the built-in functions from the text/template library in addition to some functions for converting to/from JSON.
# JSON encoding - convert value to JSON string
arguments:
data: "{{json .steps.step1.output}}"
# JSON decoding - parse JSON string to access fields
# Useful when MCP servers return JSON as text content
arguments:
name: "{{(fromJson .steps.api.output.text).user.name}}"
# String quoting
arguments:
quoted: "{{quote .params.value}}"# Comparison operators (eq, ne, lt, le, gt, ge)
condition: "{{eq .steps.step1.status \"completed\"}}"
condition: "{{ne .steps.step1.status \"failed\"}}"
condition: "{{gt .steps.step1.output.count 10}}"
# Boolean operators (and, or, not)
condition: "{{and .params.enabled (eq .steps.step1.status \"completed\")}}"
condition: "{{or .params.force (gt .steps.check.output.count 0)}}"
condition: "{{not .params.disabled}}"All Go template built-ins are available: index, len, range, with, printf, etc. See Go text/template documentation for complete reference.
spec:
failureMode: abort # Stop on first error (default)
failureMode: continue # Log errors, continue workflowsteps:
# Abort on error (default)
- id: critical
tool: payment.charge
# Uses workflow failureMode
# Continue despite errors
- id: optional
tool: notification.send
onError:
action: continue
# Retry with exponential backoff
- id: resilient
tool: external.api
onError:
action: retry
maxRetries: 3 # Max 3 retries (4 total attempts)Provide fallback values when a step may be skipped (condition) or fail (continue-on-error):
steps:
- id: optional_step
tool: enrichment.api
condition: "{{.params.enable_enrichment}}"
defaultResults:
text: "fallback value" # Used when step is skipped
- id: unreliable_step
tool: external.api
onError:
action: continue
defaultResults:
text: "{\"status\": \"unavailable\"}" # Used when step failsNotes:
- Keys in
defaultResultsmust match output fields referenced by downstream templates - Backend tools return text under
textkey, so usedefaultResults.textfor text output - Required when skippable steps are referenced by downstream templates
spec:
timeout: 30m # Workflow timeout (default: 30m)
steps:
- id: step1
timeout: 5m # Step timeout (default: 5m)Precedence: Step timeout ≤ Workflow timeout
steps:
# Fan-out: Parallel collection
- id: fetch_1
- id: fetch_2
- id: fetch_3
# Fan-in: Aggregate
- id: combine
dependsOn: [fetch_1, fetch_2, fetch_3]steps:
- id: fetch
- id: transform
dependsOn: [fetch]
- id: store
dependsOn: [transform]steps:
- id: fetch
- id: process_a
dependsOn: [fetch]
- id: process_b
dependsOn: [fetch]
- id: merge
dependsOn: [process_a, process_b]steps:
- id: get_packages
type: tool
tool: oci.get_image_config
arguments:
image: "{{.params.image}}"
- id: check_vulns
type: forEach
collection: "{{json .steps.get_packages.output.packages}}"
itemVar: pkg # defaults to "item"
maxParallel: 5 # defaults to DAG maxParallel (10)
step: # single inner step (tool only)
type: tool
tool: osv.query_vulnerability
arguments:
package_name: "{{.forEach.pkg.name}}"
dependsOn: [get_packages]
onError:
action: continue # skip failed items, don't abortOutput: {{.steps.check_vulns.output.iterations}}, .count, .completed, .failed
steps:
- id: try_primary
tool: primary.api
onError:
action: retry
maxRetries: 2
- id: use_fallback
tool: secondary.api
dependsOn: [try_primary]
condition: "{{ne .steps.try_primary.status \"completed\"}}"- ✅ Workflow name:
^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$(1-64 chars) - ✅ Step IDs must be unique
- ✅ All
dependsOnstep IDs must exist - ✅ No circular dependencies
- ✅ Tool format:
workload_id.tool_name - ✅ Max retry count: 10 (runtime capped - values > 10 are silently reduced with warning)
- ✅ Max workflow steps: 100 (runtime enforced - workflows > 100 steps fail validation)
- ✅ forEach maxIterations: 1000 (hard cap), defaults to 100
- ✅ forEach maxParallel: 50 (hard cap), defaults to DAG maxParallel (10)
- ✅ forEach inner step must be type
tool(no nested forEach or elicitation) - ✅ forEach
itemVarcannot beindex(reserved) - ✅ Annotations must not contradict the step-derived safety floor (e.g.
readOnlyHint: truewhen a step is not read-only) — a contradicting composite tool is dropped fromtools/listat runtime with a warning in the vMCP logs (more-conservative hints, e.g.readOnlyHint: false, are allowed)
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.
# In VirtualMCPCompositeToolDefinition
status:
validationStatus: Valid|Invalid
validationErrors:
- "error message here"
referencedBy:
- namespace: default
name: vmcp-server-1| Error | Cause | Fix |
|---|---|---|
| "output not found" | Missing dependsOn |
Add dependency |
| "circular dependency" | Cycle in dependsOn |
Remove cycle |
| "tool not found" | Invalid tool reference | Check workload.tool format |
| "template error" | Invalid Go template | Fix template syntax |
- ✅ Remove unnecessary
dependsOnconstraints - ✅ Group related steps in same execution level
- ✅ Set realistic timeouts based on SLAs
- ✅ Use retry for transient failures only
- ✅ Keep steps focused (one responsibility)