Skip to content

Commit b87f3dc

Browse files
authored
Merge branch 'main' into issue-2873-vmcp-default-resources
2 parents 2820b32 + c052377 commit b87f3dc

File tree

5 files changed

+129
-1
lines changed

5 files changed

+129
-1
lines changed

docs/operator/advanced-workflow-patterns.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Workflows use Go's [text/template](https://pkg.go.dev/text/template) with these
189189

190190
**Custom Functions**:
191191
- `json` - JSON encode a value
192+
- `fromJson` - Parse a JSON string into a value (useful when MCP servers return JSON as text content)
192193
- `quote` - Quote a string value
193194

194195
**Built-in Functions**: All Go template built-ins are available (`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`, `index`, `len`, `range`, `with`, `printf`, etc.)

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,18 @@ Workflows use Go's [text/template](https://pkg.go.dev/text/template) syntax with
8080

8181
### Functions
8282

83+
Composite Tools supports all the built-in functions from the [text/template](https://pkg.go.dev/text/template#hdr-Functions) library in addition to some functions for converting to/from JSON.
84+
8385
```yaml
84-
# JSON encoding
86+
# JSON encoding - convert value to JSON string
8587
arguments:
8688
data: "{{json .steps.step1.output}}"
8789

90+
# JSON decoding - parse JSON string to access fields
91+
# Useful when MCP servers return JSON as text content
92+
arguments:
93+
name: "{{(fromJson .steps.api.output.text).user.name}}"
94+
8895
# String quoting
8996
arguments:
9097
quoted: "{{quote .params.value}}"

docs/operator/virtualmcpcompositetooldefinition-guide.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ arguments:
330330
- `.params.<name>`: Access workflow parameters
331331
- `.steps.<step_id>.<field>`: Access step results (Phase 2)
332332

333+
**Available Template Functions**:
334+
335+
Composite Tools supports all the built-in functions from [text/template](https://pkg.go.dev/text/template#hdr-Functions) (`eq`, `ne`, `lt`, `le`, `gt`, `ge`, `and`, `or`, `not`, `index`, `len`, `printf`, etc.) plus custom functions:
336+
337+
- `json`: Encode a value as a JSON string
338+
- `fromJson`: Parse a JSON string into a value (useful when tools return JSON as text)
339+
- `quote`: Quote a string value
340+
333341
### Step Output Format
334342

335343
Backend tools can return results in two formats, which affects how you access the data in templates:
@@ -355,6 +363,15 @@ arguments:
355363
message: "{{.steps.echo_tool.output.text}}"
356364
```
357365

366+
If a tool returns JSON as text content, use the `fromJson` function to parse it and access fields:
367+
368+
```yaml
369+
# If api_call returns text: '{"user": {"name": "Alice", "email": "alice@example.com"}}'
370+
arguments:
371+
name: "{{(fromJson .steps.api_call.output.text).user.name}}"
372+
email: "{{(fromJson .steps.api_call.output.text).user.email}}"
373+
```
374+
358375
> **Important**: Structured content must be an object (map). If a tool returns an array, primitive, or other non-object type, it falls back to unstructured content handling.
359376

360377
### Numeric Values in Templates

pkg/vmcp/composer/template_expander.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func NewTemplateExpander() TemplateExpander {
3434
"quote": func(s string) string {
3535
return fmt.Sprintf("%q", s)
3636
},
37+
"fromJson": fromJson,
3738
},
3839
}
3940
}
@@ -245,3 +246,13 @@ func jsonEncode(v any) (string, error) {
245246
}
246247
return string(b), nil
247248
}
249+
250+
// fromJson is a template function that parses a JSON string into a value.
251+
// It is useful when the underlying MCP server does not support structured content.
252+
func fromJson(s string) (any, error) {
253+
var v any
254+
if err := json.Unmarshal([]byte(s), &v); err != nil {
255+
return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
256+
}
257+
return v, nil
258+
}

pkg/vmcp/composer/template_expander_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,95 @@ func TestTemplateExpander_WorkflowMetadataEmpty(t *testing.T) {
399399
require.NoError(t, err)
400400
assert.Equal(t, map[string]any{"id": "<no value>"}, result)
401401
}
402+
403+
func TestTemplateExpander_FromJsonFunction(t *testing.T) {
404+
t.Parallel()
405+
406+
tests := []struct {
407+
name string
408+
data map[string]any
409+
steps map[string]*StepResult
410+
expected map[string]any
411+
wantErr bool
412+
}{
413+
{
414+
name: "parse JSON from step output and access field",
415+
data: map[string]any{"name": `{{(fromJson .steps.fetch.output.text).name}}`},
416+
steps: map[string]*StepResult{
417+
"fetch": {
418+
Status: StepStatusCompleted,
419+
Output: map[string]any{"text": `{"name": "Alice", "email": "alice@example.com"}`},
420+
},
421+
},
422+
expected: map[string]any{"name": "Alice"},
423+
},
424+
{
425+
name: "parse JSON and access nested field",
426+
data: map[string]any{"email": `{{(fromJson .steps.fetch.output.text).user.email}}`},
427+
steps: map[string]*StepResult{
428+
"fetch": {
429+
Status: StepStatusCompleted,
430+
Output: map[string]any{"text": `{"user": {"email": "bob@example.com"}}`},
431+
},
432+
},
433+
expected: map[string]any{"email": "bob@example.com"},
434+
},
435+
{
436+
name: "parse JSON array and use with index",
437+
data: map[string]any{"first": `{{index (fromJson .steps.fetch.output.text) 0}}`},
438+
steps: map[string]*StepResult{
439+
"fetch": {
440+
Status: StepStatusCompleted,
441+
Output: map[string]any{"text": `["apple", "banana", "cherry"]`},
442+
},
443+
},
444+
expected: map[string]any{"first": "apple"},
445+
},
446+
{
447+
name: "combine fromJson with json function",
448+
data: map[string]any{"data": `{{json (fromJson .steps.fetch.output.text)}}`},
449+
steps: map[string]*StepResult{
450+
"fetch": {
451+
Status: StepStatusCompleted,
452+
Output: map[string]any{"text": `{"key": "value"}`},
453+
},
454+
},
455+
expected: map[string]any{"data": `{"key":"value"}`},
456+
},
457+
{
458+
name: "fromJson with invalid JSON causes error",
459+
data: map[string]any{"val": `{{(fromJson .steps.fetch.output.text).key}}`},
460+
steps: map[string]*StepResult{
461+
"fetch": {
462+
Status: StepStatusCompleted,
463+
Output: map[string]any{"text": `not valid json`},
464+
},
465+
},
466+
wantErr: true,
467+
},
468+
}
469+
470+
expander := NewTemplateExpander()
471+
472+
for _, tt := range tests {
473+
t.Run(tt.name, func(t *testing.T) {
474+
t.Parallel()
475+
476+
ctx := &WorkflowContext{
477+
WorkflowID: "test",
478+
Params: map[string]any{},
479+
Steps: tt.steps,
480+
Variables: map[string]any{},
481+
}
482+
483+
result, err := expander.Expand(context.Background(), tt.data, ctx)
484+
if tt.wantErr {
485+
require.Error(t, err)
486+
return
487+
}
488+
489+
require.NoError(t, err)
490+
assert.Equal(t, tt.expected, result)
491+
})
492+
}
493+
}

0 commit comments

Comments
 (0)