Composite tools and workflows
Composite tools let you define multi-step workflows that execute across multiple backend MCP servers with parallel execution, conditional logic, approval gates, and error handling. When a client calls a composite tool, vMCP orchestrates execution across backend MCP servers, running independent steps in parallel and waiting only where dependencies require it.
Configuration location
You can define composite tools in two ways:
Standalone resource (VirtualMCPCompositeToolDefinition) - a dedicated
Kubernetes resource that can be versioned, reviewed, and referenced by multiple
vMCP servers. This is the recommended approach for anything beyond a quick
experiment:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: my-workflow
spec:
name: my_workflow
description: A multi-step workflow
parameters:
# Input parameters (JSON Schema)
steps:
# Workflow steps
Wire it into a VirtualMCPServer with spec.config.compositeToolRefs. The
referenced definitions must be in the same namespace as the VirtualMCPServer:
spec:
config:
compositeToolRefs:
- name: my-workflow
Inline (spec.config.compositeTools) - embed the definition directly in the
VirtualMCPServer. Convenient for quick experiments, but harder to reuse or
review independently.
The rest of this guide uses VirtualMCPCompositeToolDefinition.
Simple example
This composite tool gathers the metadata, diff, and changed file list for a
GitHub pull request in a single call. It assumes you have a GitHub MCP server
deployed in a group your vMCP server references, using the default
conflict resolution strategy
and prefix format (<SERVER_NAME>_<TOOL_NAME>).
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: gather-pr-context
namespace: toolhive-system
spec:
name: gather_pr_context
description: Gather PR metadata, diff, and changed files in a single call
parameters:
type: object
properties:
owner:
type: string
description: Repository owner
repo:
type: string
description: Repository name
pullNumber:
type: number
description: Pull request number
required:
- owner
- repo
- pullNumber
steps:
# No dependsOn between these steps; they run in parallel
- id: pr_meta
tool: github_pull_request_read
arguments:
method: 'get'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_diff
tool: github_pull_request_read
arguments:
method: 'get_diff'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_files
tool: github_pull_request_read
arguments:
method: 'get_files'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
What's happening:
- Parameters:
owner,repo, andpullNumberidentify the pull request. - Parallel execution: None of the steps have a
dependsOnfield, so vMCP runs all three simultaneously. The total wall-clock time is roughly the slowest single call, not the sum. - JSON text: The GitHub MCP server returns data as JSON text in the
textfield rather than structured content. Accessing a specific field requiresfromJson, for example:{{(fromJson .steps.pr_meta.output.text).title}} - Output: Without an
outputblock, the composite returns the raw output of the last step that completed. The complete example extends this with two more parallel reads, an elicitation gate, and anoutputblock that aggregates selected fields into a single structured response.
Use cases
The examples in this section use illustrative tool names to show workflow patterns. For a complete working example against real MCP servers, see the complete example.
Incident investigation
Gather data from multiple monitoring systems in parallel:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: investigate-incident
spec:
name: investigate_incident
description: Gather incident data from multiple sources in parallel
parameters:
type: object
properties:
incident_id:
type: string
required:
- incident_id
steps:
# These steps run in parallel (no dependencies)
- id: get_logs
tool: logging_search_logs
arguments:
query: 'incident_id={{.params.incident_id}}'
timerange: '1h'
- id: get_metrics
tool: monitoring_get_metrics
arguments:
filter: 'error_rate'
timerange: '1h'
- id: get_alerts
tool: pagerduty_list_alerts
arguments:
incident: '{{.params.incident_id}}'
# This step waits for all parallel steps to complete
- id: create_summary
tool: docs_create_document
arguments:
title: 'Incident {{.params.incident_id}} Summary'
content: 'Logs: {{.steps.get_logs.output.results}}'
dependsOn: [get_logs, get_metrics, get_alerts]
Deployment with approval
Human-in-the-loop workflow for production deployments:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: deploy-with-approval
spec:
name: deploy_with_approval
description: Deploy to production with human approval gate
parameters:
type: object
properties:
pr_number:
type: string
environment:
type: string
default: production
required:
- pr_number
steps:
- id: get_pr_details
tool: scm_get_pull_request
arguments:
pr: '{{.params.pr_number}}'
- id: approval
type: elicitation
message: 'Deploy PR #{{.params.pr_number}} to {{.params.environment}}?'
schema:
type: object
properties: {}
timeout: '10m'
# Without onDecline/onCancel, a non-accept response aborts the workflow
onDecline:
action: continue
onCancel:
action: continue
dependsOn: [get_pr_details]
- id: deploy
tool: deploy_trigger_deployment
arguments:
ref: '{{.steps.get_pr_details.output.head_sha}}'
environment: '{{.params.environment}}'
# Check the action field: "accept" means the user confirmed
condition: '{{eq .steps.approval.output.action "accept"}}'
dependsOn: [approval]
defaultResults:
text: '{"status":"skipped"}'
Cross-system data aggregation
Collect and correlate data from multiple backend MCP servers:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: security-scan-report
spec:
name: security_scan_report
description: Run security scans and create consolidated report
parameters:
type: object
properties:
package_name:
type: string
ecosystem:
type: string
repo:
type: string
required:
- package_name
- ecosystem
- repo
steps:
- id: vulnerability_scan
tool: osv_query_vulnerability
arguments:
package_name: '{{.params.package_name}}'
ecosystem: '{{.params.ecosystem}}'
- id: secret_scan
tool: gitleaks_scan_repo
arguments:
repository: '{{.params.repo}}'
- id: create_issue
tool: github_create_issue
arguments:
repo: '{{.params.repo}}'
title: 'Security Scan Results'
body: 'Vulnerability scan completed for {{.params.package_name}}'
dependsOn: [vulnerability_scan, secret_scan]
onError:
action: continue
Workflow definition
Parameters
Define input parameters using JSON Schema format:
spec:
name: <TOOL_NAME>
parameters:
type: object
properties:
required_param:
type: string
optional_param:
type: integer
default: 10
required:
- required_param
Steps
Each step can be a tool call, an elicitation, or a forEach loop:
spec:
name: <TOOL_NAME>
steps:
- id: step_name # Unique identifier
tool: backend_tool # Tool to call
arguments: # Arguments with template expansion
arg1: '{{.params.input}}'
dependsOn: [other_step] # Dependencies (this step waits for other_step)
condition: '{{.steps.check.output.approved}}' # Optional condition
timeout: '30s' # Step timeout
onError:
action: abort # abort | continue | retry
The tool field specifies which MCP server tool to call. This depends on your
conflict resolution strategy
and prefix format. For example, if you have a tool named pull_request_read in
an MCP server named github, and you're using the default prefix format, you
would reference it as github_pull_request_read.
If downstream steps reference this step's output, provide default step outputs.
Elicitation (user prompts)
Request input from users during workflow execution:
spec:
name: <TOOL_NAME>
steps:
- id: approval
type: elicitation
message: 'Proceed with deployment?'
schema:
type: object
properties: {}
timeout: '5m'
onDecline:
action: continue # abort | continue
onCancel:
action: continue
Handling non-accept responses:
onDecline and onCancel control what happens when the user explicitly
declines or dismisses the prompt. Both default to abort, which fails the
workflow. Set them to continue to proceed to the next step.
When you set onDecline/onCancel to continue, gate any downstream write
action with a condition that checks the action output field:
# Gate a write step on the user accepting
- id: write_step
tool: service_write_operation
arguments:
data: '{{.steps.gather.output.result}}'
condition: '{{eq .steps.approval.output.action "accept"}}'
dependsOn: [approval]
defaultResults:
# Required when condition may be false and output block or downstream
# steps reference this step — structure must mirror what downstream expects
text: '{"status":"skipped"}'
The action field takes one of three values: accept (user confirmed),
decline (user explicitly declined), or cancel (user dismissed the prompt).
Elicitation requires MCP client support for the elicitation/create protocol
method. Clients that don't support this method will abort the workflow when an
elicitation step is reached.
forEach steps
Iterate over a collection from a previous step's output and execute a tool call for each item:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: scan-repositories
spec:
name: scan_repositories
description: Check each repository for security advisories
parameters:
type: object
properties:
org:
type: string
required:
- org
steps:
- id: list_repos
tool: scm_list_repos
arguments:
org: '{{.params.org}}'
- id: check_advisories
type: forEach
collection: '{{json .steps.list_repos.output.repositories}}'
itemVar: repo
maxParallel: 5
step:
type: tool
tool: scm_list_security_advisories
arguments:
repo: '{{.forEach.repo.name}}'
onError:
action: continue
dependsOn: [list_repos]
forEach fields:
| Field | Description | Default |
|---|---|---|
collection | Template expression that resolves to a JSON array | - |
itemVar | Variable name for the current item | item |
maxParallel | Maximum concurrent iterations (max 50) | 10 |
maxIterations | Maximum total iterations (max 1000) | 100 |
step | Inner step definition (tool call to execute per item) | - |
onError | Error handling: abort (stop) or continue (skip) | abort |
onError.action: retry is accepted by the validator on forEach steps but has
no effect at runtime. Retries only apply to regular tool steps. The
maxParallel cap of 50 is enforced at runtime regardless of the configured
value.
Access the current item inside the inner step using
{{.forEach.<itemVar>.<field>}}. In the example above, {{.forEach.repo.name}}
accesses the name field of the current repository. You can also use
{{.forEach.index}} to access the zero-based iteration index.
maxParallel controls how many iterations run concurrently on the pod that
received the composite tool request. Iterations are not distributed across
vMCP replicas; all parallel backend calls originate from a single pod regardless
of spec.replicas. When sizing your deployment, account for the per-pod
fan-out: a maxParallel: 50 forEach step can open up to 50 simultaneous
connections to backend MCP servers from one pod. Ensure both the vMCP pod
resources and the backend MCP servers can handle that per-pod concurrency.
With maxIterations: 1000 and maxParallel: 10 (the defaults), a forEach loop
runs up to 100 serial batches. If each backend call takes a few seconds, the
total duration can easily exceed a workflow-level timeout. Set the workflow
timeout to at least
ceil(maxIterations / maxParallel) × expected step duration to avoid silent
truncation.
Error handling
Configure behavior when steps fail:
| Action | Description |
|---|---|
abort | Stop workflow immediately |
continue | Log error, proceed to next step |
retry | Retry with exponential backoff |
spec:
name: <TOOL_NAME>
steps:
- id: <STEP_ID>
# ... other step config (tool, arguments, etc.)
onError:
action: retry
retryCount: 3
If downstream steps reference this step's output, provide default step outputs.
Default step outputs
When steps can be skipped (due to condition being false or
onError.action: continue), downstream steps that reference their outputs need
fallback values. Use defaultResults to provide these values.
When defaultResults are required
You must provide defaultResults when both of these conditions are true:
- A step can be skipped (has a
conditionfield oronError.action: continue) - A downstream step references the skipped step's output in its arguments
Configuration
Define default values that match the expected output structure:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: optional-security-check
spec:
name: optional_security_check
description: Run security scan with optional vulnerability check
parameters:
type: object
properties:
package_name:
type: string
ecosystem:
type: string
run_vuln_scan:
type: boolean
default: false
required:
- package_name
- ecosystem
steps:
# Step 1: Optional vulnerability scan
- id: vuln_scan
tool: osv_query_vulnerability
arguments:
package_name: '{{.params.package_name}}'
ecosystem: '{{.params.ecosystem}}'
condition: '{{.params.run_vuln_scan}}'
defaultResults:
vulns: []
# Step 2: Create report using scan results
- id: create_report
tool: docs_create_document
arguments:
title: 'Security Report'
# This references vuln_scan output, so defaultResults are needed
body: 'Found {{len .steps.vuln_scan.output.vulns}} vulnerabilities'
dependsOn: [vuln_scan]
Continue on error example
When using onError.action: continue, provide defaults for potential failures:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: multi-source-data
spec:
name: multi_source_data
description: Gather data from multiple sources, continue on failures
steps:
# Step 1: Fetch from primary source (may fail)
- id: fetch_primary
tool: api_get_data
arguments:
source: 'primary'
onError:
action: continue
defaultResults:
status: 'unavailable'
data: ''
# Step 2: Aggregate results
- id: aggregate
tool: processing_combine_data
arguments:
# Uses fetch_primary output even if it failed
primary: '{{.steps.fetch_primary.output.data}}'
dependsOn: [fetch_primary]
Validation
vMCP validates defaultResults at configuration time:
-
Missing defaults: If a step can be skipped and downstream steps reference its output, but
defaultResultsis not provided, vMCP returns a validation error. -
Structure: The
defaultResultsvalue can be any valid JSON type (object, array, string, number, boolean, null). -
No type checking: vMCP does not verify that
defaultResultsmatch the actual output structure. You must ensure they match the format your downstream steps expect. -
JSON text backends: If a backend returns data as JSON text and downstream steps or the output block access
.steps.X.output.text, yourdefaultResultsmust provide atextkey whose value is a JSON string. Providing bare key/value pairs won't work because the template expression{{.steps.X.output.text}}resolves to empty whentextisn't present. For example:# Wrong — downstream templates use .output.text, not .output.statusdefaultResults:status: 'skipped'# Correct — mirrors the .output.text access patterndefaultResults:text: '{"status":"skipped","id":0}'Use zero values (
0,"",false) rather thannullfor typed fields in the JSON string. Go templates rendernullas<no value>, which cannot be coerced tointeger,number, orbooleanoutput property types.
Example validation error
# This will fail validation
steps:
- id: conditional_step
tool: backend_fetch
condition: '{{.params.enabled}}'
# Missing defaultResults!
- id: use_result
tool: backend_process
arguments:
# References conditional_step output
data: '{{.steps.conditional_step.output.value}}'
dependsOn: [conditional_step]
Error message:
steps[conditional_step].defaultResults[value] is required: step "conditional_step"
may be skipped and field "value" is referenced by step use_result
Structured output
By default, a composite tool returns the raw output of the most recently
completed step. Add an output block to define a structured, typed response
instead. This is useful when you want to aggregate data from multiple steps,
enforce types, or give models a consistent response shape.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: create-github-issue
spec:
name: create_github_issue
description: Create an issue and return structured result
parameters:
type: object
properties:
title:
type: string
body:
type: string
required: [title, body]
steps:
- id: create
tool: github_create_issue
arguments:
title: '{{.params.title}}'
body: '{{.params.body}}'
output:
properties:
issue_number:
type: integer
description: Number of the created issue
value: '{{.steps.create.output.number}}'
issue_url:
type: string
description: URL of the created issue
value: '{{.steps.create.output.url}}'
required: [issue_number, issue_url]
Property fields
Each entry in output.properties requires type and either value or
properties:
| Field | Required | Description |
|---|---|---|
type | Yes | JSON Schema type: string, integer, number, boolean, object, array |
description | Recommended | Human-readable description exposed to clients and models |
value | Yes* | Template string that resolves to the property value |
properties | Yes* | Nested property definitions (object type only) |
default | No | Fallback value when template expansion returns no value or coercion fails |
* value and properties are mutually exclusive. Non-object types must use
value. Object types may use either.
description is optional in the CRD schema but is required by the vMCP runtime
validator. Omitting it causes the composite tool to fail at load time.
The top-level required list names properties that must be present and non-null
at runtime. If a required property is missing or null, the workflow fails.
Type coercion
Template expansion always produces a string. The runtime converts that string to the declared type:
| Type | Coercion |
|---|---|
string | Used as-is |
integer | Parsed as a base-10 integer; fails if not parseable |
number | Parsed as a float; fails if not parseable |
boolean | Accepts true/false, 1/0; fails otherwise |
object | Parsed as a JSON object string; fails if not valid JSON |
array | Parsed as a JSON array string; fails if not valid JSON |
When coercion fails and no default is defined, the workflow fails. When
default is defined, it is used as the fallback and a warning is logged.
<no value> (which is what you get when a template expression resolves to
null or a missing field) cannot be coerced to integer, number, or
boolean. If a property may be absent, either add a default value or use zero
values (0, false, "") rather than null in defaultResults JSON.
For object and array types, the value must expand to a JSON string. If a
step's structured output already contains a map or slice (not a JSON string),
use the json template function to serialize it first:
# Step returns a structured list; serialize to JSON for the output block
referrers:
type: array
description: Attestation referrers attached to this image
value: '{{json .steps.list_referrers.output.referrers}}'
The default field
Use default to provide a fallback for properties that may not resolve. For
example, when referencing output from a conditionally skipped step:
output:
properties:
processed_count:
type: integer
description: Number of items processed
value: '{{.steps.optional_step.output.count}}'
default: 0
status:
type: string
description: Processing status
value: '{{.steps.optional_step.output.status}}'
default: 'not_run'
When a step is conditionally skipped, its output fields expand to <no value>
in templates. Without a default, the workflow fails. defaultResults on the
step itself serves a different purpose: it provides fallback values for
downstream steps that reference the skipped step's output in their
arguments. See Default step outputs.
Nested objects
For object properties, use value when the step returns data to deserialize, or
properties to template each field individually:
output:
properties:
# Option 1a: step returns a JSON string already — reference it directly
metadata:
type: object
description: Metadata returned by the backend as a JSON string
value: '{{.steps.fetch.output.metadata_json}}'
# Option 1b: step returns structured data — use json to serialize it first
referrers:
type: array
description: OCI referrers attached to this image
value: '{{json .steps.list_referrers.output.referrers}}'
# Option 2: nested properties, each individually templated
summary:
type: object
description: Aggregated scan summary
properties:
issue_count:
type: integer
description: Total issues found
value: '{{.steps.scan.output.count}}'
severity:
type: string
description: Highest severity level
value: '{{.steps.scan.output.max_severity}}'
Aggregating from multiple steps
The output block is the natural place to collect results from parallel or
sequential steps into a single response. This example fans out three lookups in
parallel, then aggregates them into one structured result:
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: image-supply-chain-audit
spec:
name: audit_image_supply_chain
description: Audit an OCI image (info, referrers, and recent tags in parallel)
parameters:
type: object
properties:
image_ref:
type: string
description: Full image reference including tag
repository:
type: string
description: Repository path without tag, for listing tags
required: [image_ref, repository]
steps:
# All three steps run in parallel — no dependsOn between them
- id: image_info
tool: oci-registry_get_image_info
arguments:
image_ref: '{{.params.image_ref}}'
- id: referrers
tool: oci-registry_list_referrers
arguments:
image_ref: '{{.params.image_ref}}'
- id: tags
tool: oci-registry_list_tags
arguments:
repository: '{{.params.repository}}'
limit: 10
output:
properties:
image_ref:
type: string
description: The image reference that was audited
value: '{{.params.image_ref}}'
digest:
type: string
description: Content-addressable digest of the image
value: '{{.steps.image_info.output.digest}}'
architecture:
type: string
description: CPU architecture of the image
value: '{{.steps.image_info.output.architecture}}'
layers:
type: integer
description: Number of image layers
value: '{{.steps.image_info.output.layers}}'
# referrers is a structured list — use json to serialize before output parses it
referrers:
type: array
description:
Attestation referrers attached to this image (SBOMs, signatures)
value: '{{json .steps.referrers.output.referrers}}'
recent_tags:
type: array
description: Most recent tags in the repository
value: '{{json .steps.tags.output.tags}}'
required: [digest]
Template syntax
Access workflow context in arguments:
| Template | Description |
|---|---|
{{.params.name}} | Input parameter |
{{.steps.id.output}} | Step output (map) |
{{.steps.id.output.text}} | Text content from step output |
{{.steps.id.output.content}} | Elicitation response content |
{{.steps.id.output.action}} | Elicitation action (accept/decline/cancel) |
{{.forEach.<itemVar>}} | Current forEach item |
{{.forEach.<itemVar>.<field>}} | Field on current forEach item |
{{.forEach.index}} | Zero-based iteration index |
Template functions
| Function | Description | Example |
|---|---|---|
fromJson | Parse a JSON string into a value | {{(fromJson .steps.s1.output.text).field}} |
json | Encode a value as a JSON string | {{json .steps.s1.output}} |
quote | Quote a string value | {{quote .params.name}} |
index | Access array elements by index | {{index .steps.s1.output.items 0}} |
All
Go template built-in functions
are also supported (e.g., len, eq, and, or, printf).
Accessing step outputs
When an MCP server returns structured content, you can access output fields directly:
# Direct access when server supports structuredContent
result: '{{.steps.fetch.output.data}}'
items: '{{index .steps.search.output.results 0}}'
This is the simplest approach and works when the backend MCP server populates
the structuredContent field in its response.
Working with JSON text responses
Some MCP servers return structured data as JSON text rather than using MCP's
structuredContent field. When this happens, use fromJson to parse it:
# Parse JSON text and access a nested field
pr_title: '{{(fromJson .steps.pr_meta.output.text).title}}'
pr_author: '{{(fromJson .steps.pr_meta.output.text).user.login}}'
This pattern:
- Gets the text output:
.steps.pr_meta.output.text - Parses it as JSON:
fromJson ... - Accesses the desired field:
.title,.user.login, etc.
How to tell which approach to use: Call the backend tool directly and
inspect the response. If structuredContent contains your data fields, use
direct access. If structuredContent only has a text field containing JSON,
use fromJson.
JSON text in the output block
When using a JSON text backend and including the data in the output block,
avoid using fromJson to pass through a whole array or object. The Go template
engine renders parsed values using Go's native format, not JSON, so the result
is not valid JSON and type coercion fails.
# Wrong — Go renders the parsed array as "[map[sha:... filename:...]]"
changedFiles:
type: array
description: Changed files
value: '{{fromJson .steps.pr_files.output.text}}'
# Option 1 (simplest): pass the raw JSON text through as a string
changedFiles:
type: string
description: Changed files (JSON array)
value: '{{.steps.pr_files.output.text}}'
# Option 2: re-serialize with json after parsing (produces valid JSON)
changedFiles:
type: array
description: Changed files
value: '{{json (fromJson .steps.pr_files.output.text)}}'
Option 1 is simpler and avoids two conversions. Clients consuming the output
should parse the string as JSON. Option 2 is useful when you want the output
type to be array or object for schema accuracy.
Complete example
This is the full prepare_pr_review composite tool, which extends the
simple example with two more parallel reads, an elicitation
gate, a conditional write step, and a structured output block.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: VirtualMCPCompositeToolDefinition
metadata:
name: prepare-pr-review
namespace: toolhive-system
spec:
name: prepare_pr_review
description: >
Gather full PR context in parallel and optionally create a pending review.
Returns PR metadata, changed files, and existing reviews in a single
consolidated response.
parameters:
type: object
properties:
owner:
type: string
description: Repository owner
repo:
type: string
description: Repository name
pullNumber:
type: number
description: Pull request number
required:
- owner
- repo
- pullNumber
timeout: '5m'
steps:
# All five reads run in parallel — no dependsOn between them
- id: pr_meta
tool: github_pull_request_read
arguments:
method: 'get'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_diff
tool: github_pull_request_read
arguments:
method: 'get_diff'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_files
tool: github_pull_request_read
arguments:
method: 'get_files'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_reviews
tool: github_pull_request_read
arguments:
method: 'get_reviews'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
- id: pr_review_comments
tool: github_pull_request_read
arguments:
method: 'get_review_comments'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
# Elicitation: gate the write action on user acceptance
- id: approval
type: elicitation
message: PR context gathered. Create a pending review?
schema:
type: object
properties: {}
timeout: '5m'
onDecline:
action: continue
onCancel:
action: continue
dependsOn:
- pr_meta
- pr_diff
- pr_files
- pr_reviews
- pr_review_comments
# Conditional write: only runs when the user accepts
- id: create_review
tool: github_pull_request_review_write
arguments:
method: 'create'
owner: '{{.params.owner}}'
repo: '{{.params.repo}}'
pullNumber: '{{.params.pullNumber}}'
condition: '{{eq .steps.approval.output.action "accept"}}'
dependsOn:
- approval
defaultResults:
text: '{"status":"skipped"}'
output:
properties:
summary:
type: object
description: Core PR metadata
properties:
title:
type: string
description: PR title
value: '{{(fromJson .steps.pr_meta.output.text).title}}'
state:
type: string
description: PR state (open, closed, merged)
value: '{{(fromJson .steps.pr_meta.output.text).state}}'
author:
type: string
description: GitHub login of the PR author
value: '{{(fromJson .steps.pr_meta.output.text).user.login}}'
url:
type: string
description: HTML URL of the pull request
value: '{{(fromJson .steps.pr_meta.output.text).html_url}}'
# GitHub returns JSON text — pass through as string rather than
# using fromJson + type: array (see JSON text in the output block)
changedFiles:
type: string
description: Files changed in the PR with patch stats (JSON array)
value: '{{.steps.pr_files.output.text}}'
existingReviews:
type: string
description:
Existing reviews with reviewer and approval state (JSON array)
value: '{{.steps.pr_reviews.output.text}}'
reviewCreationResult:
type: string
description: Result of the optional create_review step
value: '{{.steps.create_review.output.text}}'
required:
- summary
- changedFiles
This example assumes a GitHub MCP server is deployed in a group referenced by
your VirtualMCPServer, using the default prefix format so the tool is
addressable as github_pull_request_read. See the
tool aggregation guide for how to configure groups and
conflict resolution.
Next steps
- Configure failure handling for circuit breakers and partial failure modes in multi-backend setups