Skip to content

Commit 37f7735

Browse files
JAORMXclaude
andcommitted
Make embedded resource content accessible from composite tool templates
ContentArrayToMap previously ignored EmbeddedResource content types, making it impossible for composite tool workflows to chain on embedded resource data (e.g., an SBOM returned by get_referrer_content). Handle ContentTypeResource in ContentArrayToMap with "resource", "resource_1", "resource_2" key pattern. When structuredContent is present, merge content array keys into it without overwriting, so both structured metadata and actual content are template-accessible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6b8f044 commit 37f7735

5 files changed

Lines changed: 224 additions & 9 deletions

File tree

pkg/vmcp/client/client.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -657,10 +657,19 @@ func (h *httpBackendClient) CallTool(
657657
}
658658
}
659659

660-
// If no structured content, convert result contents to a map for backward compatibility.
661-
// MCP tools return an array of Content interface (TextContent, ImageContent, etc.).
662-
// Text content is stored under "text" key, accessible via {{.steps.stepID.output.text}}.
663-
if structuredContent == nil {
660+
if structuredContent != nil {
661+
// Merge content array keys (text, resource, etc.) without overwriting structured keys.
662+
// This makes both the tool's structured metadata and actual content accessible.
663+
contentMap := conversion.ContentArrayToMap(contentArray)
664+
for k, v := range contentMap {
665+
if _, exists := structuredContent[k]; !exists {
666+
structuredContent[k] = v
667+
}
668+
}
669+
} else {
670+
// No structured content: convert result contents to a map for backward compatibility.
671+
// MCP tools return an array of Content interface (TextContent, ImageContent, etc.).
672+
// Text content is stored under "text" key, accessible via {{.steps.stepID.output.text}}.
664673
structuredContent = conversion.ContentArrayToMap(contentArray)
665674
}
666675

pkg/vmcp/composer/workflow_engine_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,3 +846,64 @@ func TestWorkflowEngine_SessionEngine_ToolNotInList_ReturnsNilSchema(t *testing.
846846
require.NoError(t, err)
847847
assert.Equal(t, WorkflowStatusCompleted, result.Status)
848848
}
849+
850+
func TestWorkflowEngine_EmbeddedResourceAccessibleFromTemplate(t *testing.T) {
851+
t.Parallel()
852+
te := newTestEngine(t)
853+
854+
// Step 1: tool returns structuredContent + embedded resource in Content array.
855+
// The client layer merges content array keys into structuredContent.
856+
// Step 2: uses {{.steps.fetch.output.resource}} from the merged result.
857+
def := simpleWorkflow("resource-chain",
858+
toolStep("fetch", "registry.get_referrer_content", map[string]any{
859+
"image": "ghcr.io/org/repo:latest",
860+
}),
861+
toolStepWithDeps("analyze", "sbom.analyze", map[string]any{
862+
"sbom_data": "{{.steps.fetch.output.resource}}",
863+
"format": "{{.steps.fetch.output.format}}",
864+
}, []string{"fetch"}),
865+
)
866+
867+
// Step 1 returns merged structuredContent (as the real client would after the fix).
868+
target := &vmcp.BackendTarget{
869+
WorkloadID: "test-backend",
870+
WorkloadName: "test",
871+
BaseURL: "http://test:8080",
872+
}
873+
te.Router.EXPECT().RouteTool(gomock.Any(), "registry.get_referrer_content").Return(target, nil)
874+
te.Backend.EXPECT().CallTool(gomock.Any(), target, "registry.get_referrer_content",
875+
map[string]any{"image": "ghcr.io/org/repo:latest"}, gomock.Any()).
876+
Return(&vmcp.ToolCallResult{
877+
StructuredContent: map[string]any{
878+
"contentType": "sbom",
879+
"format": "spdx",
880+
"text": "summary of SBOM",
881+
"resource": `{"spdxVersion":"SPDX-2.3","name":"mypackage"}`,
882+
},
883+
Content: []vmcp.Content{
884+
{Type: vmcp.ContentTypeText, Text: "summary of SBOM"},
885+
{Type: vmcp.ContentTypeResource, Text: `{"spdxVersion":"SPDX-2.3","name":"mypackage"}`, URI: "file://sbom.json"},
886+
},
887+
}, nil)
888+
889+
// Step 2: verify that the template-expanded args contain the resource data.
890+
te.Router.EXPECT().RouteTool(gomock.Any(), "sbom.analyze").Return(target, nil)
891+
te.Backend.EXPECT().CallTool(gomock.Any(), target, "sbom.analyze", gomock.Any(), gomock.Any()).
892+
DoAndReturn(func(_ context.Context, _ *vmcp.BackendTarget, _ string, args map[string]any, _ map[string]any) (*vmcp.ToolCallResult, error) {
893+
// Verify the template expanded the resource content correctly
894+
assert.Equal(t, `{"spdxVersion":"SPDX-2.3","name":"mypackage"}`, args["sbom_data"])
895+
assert.Equal(t, "spdx", args["format"])
896+
return &vmcp.ToolCallResult{
897+
StructuredContent: map[string]any{"result": "analyzed"},
898+
Content: []vmcp.Content{},
899+
}, nil
900+
})
901+
902+
result, err := execute(t, te.Engine, def, nil)
903+
904+
require.NoError(t, err)
905+
assert.Equal(t, WorkflowStatusCompleted, result.Status)
906+
assert.Len(t, result.Steps, 2)
907+
assert.Equal(t, StepStatusCompleted, result.Steps["fetch"].Status)
908+
assert.Equal(t, StepStatusCompleted, result.Steps["analyze"].Status)
909+
}

pkg/vmcp/conversion/content.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,10 @@ func ToMCPToolAnnotations(annotations *vmcp.ToolAnnotations) mcp.ToolAnnotation
376376
// - First text content: key="text"
377377
// - Subsequent text content: key="text_1", "text_2", etc.
378378
// - Image content: key="image_0", "image_1", etc.
379+
// - First resource content: key="resource" (text resources use .Text, blob resources use .Data)
380+
// - Subsequent resource content: key="resource_1", "resource_2", etc.
379381
// - Audio content: ignored (not supported for template substitution)
380-
// - Resource content: ignored (handled separately, not converted to map)
382+
// - Resource links: ignored (not supported for template substitution)
381383
// - Unknown content types: ignored (warnings logged at conversion boundaries)
382384
//
383385
// This ensures consistent behavior between client response handling and workflow step output processing.
@@ -389,6 +391,7 @@ func ContentArrayToMap(content []vmcp.Content) map[string]any {
389391

390392
textIndex := 0
391393
imageIndex := 0
394+
resourceIndex := 0
392395

393396
for _, item := range content {
394397
switch item.Type {
@@ -405,10 +408,23 @@ func ContentArrayToMap(content []vmcp.Content) map[string]any {
405408
result[key] = item.Data
406409
imageIndex++
407410

408-
case vmcp.ContentTypeAudio, vmcp.ContentTypeResource, vmcp.ContentTypeLink:
411+
case vmcp.ContentTypeResource:
412+
key := "resource"
413+
if resourceIndex > 0 {
414+
key = fmt.Sprintf("resource_%d", resourceIndex)
415+
}
416+
// Text resources use .Text, blob resources use .Data
417+
value := item.Text
418+
if value == "" {
419+
value = item.Data
420+
}
421+
result[key] = value
422+
resourceIndex++
423+
424+
case vmcp.ContentTypeAudio, vmcp.ContentTypeLink:
409425
// Purposely ignored for template substitution:
410426
// - Audio content is ignored (not supported for template substitution)
411-
// - Resource content/link is handled separately, not converted to map
427+
// - Resource links are ignored (not supported for template substitution)
412428
default:
413429
// Unknown content types are ignored (warnings logged at conversion boundaries)
414430
}

pkg/vmcp/conversion/conversion_test.go

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,58 @@ func TestContentArrayToMap(t *testing.T) {
686686
content: []vmcp.Content{
687687
{Type: vmcp.ContentTypeText, Text: "Text"},
688688
{Type: "unknown", Text: "Should be ignored"},
689-
{Type: vmcp.ContentTypeResource, URI: "file://test"},
689+
},
690+
expected: map[string]any{
691+
"text": "Text",
692+
},
693+
},
694+
{
695+
name: "single text resource content",
696+
content: []vmcp.Content{
697+
{Type: vmcp.ContentTypeResource, Text: "SBOM JSON data", URI: "file://sbom.json", MimeType: "application/json"},
698+
},
699+
expected: map[string]any{
700+
"resource": "SBOM JSON data",
701+
},
702+
},
703+
{
704+
name: "single blob resource content uses Data field",
705+
content: []vmcp.Content{
706+
{Type: vmcp.ContentTypeResource, Data: "base64blobdata", URI: "file://binary", MimeType: "application/octet-stream"},
707+
},
708+
expected: map[string]any{
709+
"resource": "base64blobdata",
710+
},
711+
},
712+
{
713+
name: "multiple resource contents",
714+
content: []vmcp.Content{
715+
{Type: vmcp.ContentTypeResource, Text: "First resource", URI: "file://a"},
716+
{Type: vmcp.ContentTypeResource, Text: "Second resource", URI: "file://b"},
717+
{Type: vmcp.ContentTypeResource, Data: "Third blob", URI: "file://c"},
718+
},
719+
expected: map[string]any{
720+
"resource": "First resource",
721+
"resource_1": "Second resource",
722+
"resource_2": "Third blob",
723+
},
724+
},
725+
{
726+
name: "mixed text and resource content",
727+
content: []vmcp.Content{
728+
{Type: vmcp.ContentTypeText, Text: "summary"},
729+
{Type: vmcp.ContentTypeResource, Text: "SBOM JSON", URI: "file://sbom.json"},
730+
},
731+
expected: map[string]any{
732+
"text": "summary",
733+
"resource": "SBOM JSON",
734+
},
735+
},
736+
{
737+
name: "resource link content is still ignored",
738+
content: []vmcp.Content{
739+
{Type: vmcp.ContentTypeText, Text: "Text"},
740+
{Type: vmcp.ContentTypeLink, URI: "file://link", Name: "link"},
690741
},
691742
expected: map[string]any{
692743
"text": "Text",
@@ -735,6 +786,76 @@ func TestContentArrayToMap(t *testing.T) {
735786
}
736787
}
737788

789+
func TestContentArrayToMap_MergeWithStructuredContent(t *testing.T) {
790+
t.Parallel()
791+
792+
tests := []struct {
793+
name string
794+
structuredContent map[string]any
795+
content []vmcp.Content
796+
expected map[string]any
797+
}{
798+
{
799+
name: "content array keys merged into structuredContent without overwriting",
800+
structuredContent: map[string]any{
801+
"contentType": "sbom",
802+
"format": "spdx",
803+
"size": float64(5347),
804+
},
805+
content: []vmcp.Content{
806+
{Type: vmcp.ContentTypeText, Text: "summary"},
807+
{Type: vmcp.ContentTypeResource, Text: `{"spdxVersion":"SPDX-2.3"}`, URI: "file://sbom.json"},
808+
},
809+
expected: map[string]any{
810+
"contentType": "sbom",
811+
"format": "spdx",
812+
"size": float64(5347),
813+
"text": "summary",
814+
"resource": `{"spdxVersion":"SPDX-2.3"}`,
815+
},
816+
},
817+
{
818+
name: "structuredContent keys are not overwritten by content array",
819+
structuredContent: map[string]any{
820+
"text": "structured text takes priority",
821+
},
822+
content: []vmcp.Content{
823+
{Type: vmcp.ContentTypeText, Text: "content array text"},
824+
},
825+
expected: map[string]any{
826+
"text": "structured text takes priority",
827+
},
828+
},
829+
{
830+
name: "empty content array leaves structuredContent unchanged",
831+
structuredContent: map[string]any{
832+
"key": "value",
833+
},
834+
content: []vmcp.Content{},
835+
expected: map[string]any{"key": "value"},
836+
},
837+
}
838+
839+
for _, tt := range tests {
840+
t.Run(tt.name, func(t *testing.T) {
841+
t.Parallel()
842+
843+
// Simulate the merge logic from client.go / mcp_session.go
844+
result := make(map[string]any, len(tt.structuredContent))
845+
for k, v := range tt.structuredContent {
846+
result[k] = v
847+
}
848+
contentMap := conversion.ContentArrayToMap(tt.content)
849+
for k, v := range contentMap {
850+
if _, exists := result[k]; !exists {
851+
result[k] = v
852+
}
853+
}
854+
assert.Equal(t, tt.expected, result)
855+
})
856+
}
857+
}
858+
738859
func TestFromMCPMeta(t *testing.T) {
739860
t.Parallel()
740861

pkg/vmcp/session/internal/backend/mcp_session.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,15 @@ func (c *mcpSession) CallTool(
125125
structuredContent = m
126126
}
127127
}
128-
if structuredContent == nil {
128+
if structuredContent != nil {
129+
// Merge content array keys (text, resource, etc.) without overwriting structured keys.
130+
contentMap := conversion.ContentArrayToMap(contentArray)
131+
for k, v := range contentMap {
132+
if _, exists := structuredContent[k]; !exists {
133+
structuredContent[k] = v
134+
}
135+
}
136+
} else {
129137
structuredContent = conversion.ContentArrayToMap(contentArray)
130138
}
131139

0 commit comments

Comments
 (0)