Skip to content

Commit d30b42b

Browse files
committed
schema and config changes for vMCP: Composition - Output Aggregation for Multi-Step Workflows
Successfully added the OutputFormat / outputFormat field to all configuration layers for vMCP composite tool workflows. This PR lays the foundation for implementing output aggregation in multi-step workflows as described in issue #174.
1 parent 8ce0337 commit d30b42b

File tree

7 files changed

+180
-6
lines changed

7 files changed

+180
-6
lines changed

cmd/thv-operator/api/v1alpha1/virtualmcpcompositetooldefinition_types.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ type VirtualMCPCompositeToolDefinitionSpec struct {
4646
// +kubebuilder:default=abort
4747
// +optional
4848
FailureMode string `json:"failureMode,omitempty"`
49+
50+
// OutputFormat is an optional Go template for constructing the workflow output.
51+
// If specified, the template is evaluated with access to:
52+
// - .params.* - Input parameters
53+
// - .steps.*.output - Step outputs
54+
// - .steps.*.status - Step status
55+
// - .workflow.* - Workflow metadata (id, duration, timestamps)
56+
//
57+
// The template must produce valid JSON. If omitted, defaults to returning
58+
// the last step's output (backward compatible behavior).
59+
//
60+
// Example:
61+
// outputFormat: |
62+
// {
63+
// "data": {
64+
// "logs": {{.steps.fetch_logs.output}},
65+
// "metrics": {{.steps.fetch_metrics.output}}
66+
// },
67+
// "metadata": {
68+
// "workflow_id": "{{.workflow.id}}",
69+
// "duration_ms": {{.workflow.duration_ms}}
70+
// }
71+
// }
72+
//
73+
// +optional
74+
OutputFormat string `json:"outputFormat,omitempty"`
4975
}
5076

5177
// VirtualMCPCompositeToolDefinitionStatus defines the observed state of VirtualMCPCompositeToolDefinition

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,31 @@ spec:
9292
minLength: 1
9393
pattern: ^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$
9494
type: string
95+
outputFormat:
96+
description: |-
97+
OutputFormat is an optional Go template for constructing the workflow output.
98+
If specified, the template is evaluated with access to:
99+
- .params.* - Input parameters
100+
- .steps.*.output - Step outputs
101+
- .steps.*.status - Step status
102+
- .workflow.* - Workflow metadata (id, duration, timestamps)
103+
104+
The template must produce valid JSON. If omitted, defaults to returning
105+
the last step's output (backward compatible behavior).
106+
107+
Example:
108+
outputFormat: |
109+
{
110+
"data": {
111+
"logs": {{.steps.fetch_logs.output}},
112+
"metrics": {{.steps.fetch_metrics.output}}
113+
},
114+
"metadata": {
115+
"workflow_id": "{{.workflow.id}}",
116+
"duration_ms": {{.workflow.duration_ms}}
117+
}
118+
}
119+
type: string
95120
parameters:
96121
additionalProperties:
97122
description: ParameterSpec defines a parameter for a composite tool

docs/operator/crd-api.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/vmcp/composer/composer.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ type WorkflowDefinition struct {
5959
// Options: "abort" (default), "continue", "best_effort"
6060
FailureMode string
6161

62+
// OutputFormat is an optional Go template for constructing the workflow output.
63+
// If specified, the template is evaluated with access to:
64+
// - .params.* - Input parameters
65+
// - .steps.*.output - Step outputs
66+
// - .steps.*.status - Step status
67+
// - .workflow.* - Workflow metadata (id, duration, timestamps)
68+
//
69+
// The template must produce valid JSON. If omitted, defaults to returning
70+
// the last step's output (backward compatible behavior).
71+
OutputFormat string
72+
6273
// Metadata stores additional workflow information.
6374
Metadata map[string]string
6475
}

pkg/vmcp/config/config.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,30 @@ type CompositeToolConfig struct {
339339

340340
// Steps are the workflow steps to execute.
341341
Steps []*WorkflowStepConfig `json:"steps"`
342+
343+
// OutputFormat is an optional template for constructing the workflow output.
344+
// If specified, the template is evaluated with access to:
345+
// - .params.* - Input parameters
346+
// - .steps.*.output - Step outputs
347+
// - .steps.*.status - Step status
348+
// - .workflow.* - Workflow metadata (id, duration, timestamps)
349+
//
350+
// The template must produce valid JSON. If omitted, defaults to returning
351+
// the last step's output (backward compatible behavior).
352+
//
353+
// Example:
354+
// output_format: |
355+
// {
356+
// "data": {
357+
// "logs": {{.steps.fetch_logs.output}},
358+
// "metrics": {{.steps.fetch_metrics.output}}
359+
// },
360+
// "metadata": {
361+
// "workflow_id": "{{.workflow.id}}",
362+
// "duration_ms": {{.workflow.duration_ms}}
363+
// }
364+
// }
365+
OutputFormat string `json:"output_format,omitempty" yaml:"output_format,omitempty"`
342366
}
343367

344368
// ParameterSchema defines a workflow parameter.

pkg/vmcp/server/workflow_converter.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,13 @@ func ConvertConfigToWorkflowDefinitions(
7575

7676
// Create workflow definition
7777
def := &composer.WorkflowDefinition{
78-
Name: ct.Name,
79-
Description: ct.Description,
80-
Parameters: params,
81-
Steps: steps,
82-
Timeout: timeout,
83-
Metadata: make(map[string]string),
78+
Name: ct.Name,
79+
Description: ct.Description,
80+
Parameters: params,
81+
Steps: steps,
82+
Timeout: timeout,
83+
OutputFormat: ct.OutputFormat,
84+
Metadata: make(map[string]string),
8485
}
8586

8687
workflowDefs[ct.Name] = def

pkg/vmcp/server/workflow_converter_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,89 @@ func TestConvertSteps_ComplexWorkflow(t *testing.T) {
264264
assert.NotEmpty(t, result[2].Condition)
265265
assert.Equal(t, []string{"confirm"}, result[2].DependsOn)
266266
}
267+
268+
func TestConvertConfigToWorkflowDefinitions_WithOutputFormat(t *testing.T) {
269+
t.Parallel()
270+
271+
tests := []struct {
272+
name string
273+
input []*config.CompositeToolConfig
274+
wantOutputFormat string
275+
}{
276+
{
277+
name: "workflow without output_format",
278+
input: []*config.CompositeToolConfig{{
279+
Name: "simple",
280+
Steps: []*config.WorkflowStepConfig{
281+
{ID: "s1", Type: "tool", Tool: "backend.tool"},
282+
},
283+
}},
284+
wantOutputFormat: "",
285+
},
286+
{
287+
name: "workflow with output_format",
288+
input: []*config.CompositeToolConfig{{
289+
Name: "aggregated",
290+
Steps: []*config.WorkflowStepConfig{
291+
{ID: "fetch_logs", Type: "tool", Tool: "splunk.fetch"},
292+
{ID: "fetch_metrics", Type: "tool", Tool: "datadog.fetch"},
293+
},
294+
OutputFormat: `{
295+
"logs": {{.steps.fetch_logs.output}},
296+
"metrics": {{.steps.fetch_metrics.output}}
297+
}`,
298+
}},
299+
wantOutputFormat: `{
300+
"logs": {{.steps.fetch_logs.output}},
301+
"metrics": {{.steps.fetch_metrics.output}}
302+
}`,
303+
},
304+
{
305+
name: "workflow with complex output_format",
306+
input: []*config.CompositeToolConfig{{
307+
Name: "investigation",
308+
Steps: []*config.WorkflowStepConfig{
309+
{ID: "fetch_data", Type: "tool", Tool: "backend.fetch"},
310+
{ID: "analyze", Type: "tool", Tool: "backend.analyze"},
311+
},
312+
OutputFormat: `{
313+
"data": {{.steps.fetch_data.output}},
314+
"analysis": {{.steps.analyze.output}},
315+
"metadata": {
316+
"workflow_id": "{{.workflow.id}}",
317+
"duration_ms": {{.workflow.duration_ms}}
318+
}
319+
}`,
320+
}},
321+
wantOutputFormat: `{
322+
"data": {{.steps.fetch_data.output}},
323+
"analysis": {{.steps.analyze.output}},
324+
"metadata": {
325+
"workflow_id": "{{.workflow.id}}",
326+
"duration_ms": {{.workflow.duration_ms}}
327+
}
328+
}`,
329+
},
330+
}
331+
332+
for _, tt := range tests {
333+
t.Run(tt.name, func(t *testing.T) {
334+
t.Parallel()
335+
336+
result, err := ConvertConfigToWorkflowDefinitions(tt.input)
337+
338+
require.NoError(t, err)
339+
require.Len(t, result, 1)
340+
341+
// Get the first (and only) workflow definition
342+
var workflowDef *composer.WorkflowDefinition
343+
for _, def := range result {
344+
workflowDef = def
345+
break
346+
}
347+
348+
require.NotNil(t, workflowDef)
349+
assert.Equal(t, tt.wantOutputFormat, workflowDef.OutputFormat)
350+
})
351+
}
352+
}

0 commit comments

Comments
 (0)