Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pkg/vmcp/client/meta_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestMetaPreservation_CallTool(t *testing.T) {
// Verify content is also correct
assert.NotNil(t, result.Content)
assert.Len(t, result.Content, 1)
assert.Equal(t, "text", result.Content[0].Type)
assert.Equal(t, vmcp.ContentTypeText, result.Content[0].Type)
assert.Equal(t, "Response from test tool", result.Content[0].Text)
}

Expand Down Expand Up @@ -111,7 +111,7 @@ func TestMetaPreservation_CallTool_NoMeta(t *testing.T) {
// Verify content is still correct
assert.NotNil(t, result.Content)
assert.Len(t, result.Content, 1)
assert.Equal(t, "text", result.Content[0].Type)
assert.Equal(t, vmcp.ContentTypeText, result.Content[0].Type)
}

// TestMetaPreservation_CallTool_Error tests that _meta fields are preserved even when tool returns IsError=true.
Expand Down
6 changes: 3 additions & 3 deletions pkg/vmcp/composer/workflow_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ func TestWorkflowEngine_ExecuteWorkflow_IsErrorHandling(t *testing.T) {
Return(&vmcp.ToolCallResult{
IsError: true,
Content: []vmcp.Content{{
Type: "text",
Type: vmcp.ContentTypeText,
Text: "Tool execution failed: invalid input",
}},
}, nil),
te.Backend.EXPECT().CallTool(gomock.Any(), target, "test.tool", gomock.Any(), gomock.Any()).
Return(&vmcp.ToolCallResult{
IsError: true,
Content: []vmcp.Content{{
Type: "text",
Type: vmcp.ContentTypeText,
Text: "Tool execution failed: temporary error",
}},
}, nil),
Expand Down Expand Up @@ -190,7 +190,7 @@ func TestWorkflowEngine_ExecuteWorkflow_IsErrorExhaustsRetries(t *testing.T) {
Return(&vmcp.ToolCallResult{
IsError: true,
Content: []vmcp.Content{{
Type: "text",
Type: vmcp.ContentTypeText,
Text: "Persistent error: operation failed",
}},
}, nil).Times(3) // Initial + 2 retries
Expand Down
53 changes: 30 additions & 23 deletions pkg/vmcp/conversion/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,36 @@ import (
"github.com/stacklok/toolhive/pkg/vmcp"
)

const (
contentTypeText = "text"
contentTypeImage = "image"
contentTypeAudio = "audio"
contentTypeResource = "resource"
)

// ConvertMCPContent converts a single mcp.Content item to vmcp.Content.
// Unknown content types are returned as vmcp.Content{Type: "unknown"}.
func ConvertMCPContent(content mcp.Content) vmcp.Content {
if text, ok := mcp.AsTextContent(content); ok {
return vmcp.Content{Type: contentTypeText, Text: text.Text}
return vmcp.Content{Type: vmcp.ContentTypeText, Text: text.Text}
}
if img, ok := mcp.AsImageContent(content); ok {
return vmcp.Content{Type: contentTypeImage, Data: img.Data, MimeType: img.MIMEType}
return vmcp.Content{Type: vmcp.ContentTypeImage, Data: img.Data, MimeType: img.MIMEType}
}
if audio, ok := mcp.AsAudioContent(content); ok {
return vmcp.Content{Type: contentTypeAudio, Data: audio.Data, MimeType: audio.MIMEType}
return vmcp.Content{Type: vmcp.ContentTypeAudio, Data: audio.Data, MimeType: audio.MIMEType}
}
if res, ok := mcp.AsEmbeddedResource(content); ok {
if textRes, ok := mcp.AsTextResourceContents(res.Resource); ok {
return vmcp.Content{Type: "resource", Text: textRes.Text, URI: textRes.URI, MimeType: textRes.MIMEType}
return vmcp.Content{Type: vmcp.ContentTypeResource, Text: textRes.Text, URI: textRes.URI, MimeType: textRes.MIMEType}
}
if blobRes, ok := mcp.AsBlobResourceContents(res.Resource); ok {
return vmcp.Content{Type: "resource", Data: blobRes.Blob, URI: blobRes.URI, MimeType: blobRes.MIMEType}
return vmcp.Content{Type: vmcp.ContentTypeResource, Data: blobRes.Blob, URI: blobRes.URI, MimeType: blobRes.MIMEType}
}
slog.Debug("Embedded resource has unknown resource contents type", "type", fmt.Sprintf("%T", res.Resource))
return vmcp.Content{Type: "resource"}
return vmcp.Content{Type: vmcp.ContentTypeResource}
}
if link, ok := content.(mcp.ResourceLink); ok {
return vmcp.Content{
Type: vmcp.ContentTypeLink,
URI: link.URI,
Name: link.Name,
Description: link.Description,
MimeType: link.MIMEType,
}
}
slog.Debug("Encountered unknown MCP content type", "type", fmt.Sprintf("%T", content))
return vmcp.Content{Type: "unknown"}
Expand All @@ -64,13 +66,13 @@ func ConvertMCPContents(contents []mcp.Content) []vmcp.Content {
// Unknown content types are converted to empty text with a warning.
func ToMCPContent(content vmcp.Content) mcp.Content {
switch content.Type {
case contentTypeText:
case vmcp.ContentTypeText:
return mcp.NewTextContent(content.Text)
case contentTypeImage:
case vmcp.ContentTypeImage:
return mcp.NewImageContent(content.Data, content.MimeType)
case contentTypeAudio:
case vmcp.ContentTypeAudio:
return mcp.NewAudioContent(content.Data, content.MimeType)
case contentTypeResource:
case vmcp.ContentTypeResource:
// Reconstruct embedded resource from vmcp.Content fields.
// Text content takes precedence over blob content when both are present.
if content.Text != "" {
Expand All @@ -94,6 +96,9 @@ func ToMCPContent(content vmcp.Content) mcp.Content {
MIMEType: content.MimeType,
Text: "",
})
case vmcp.ContentTypeLink:
// Reconstruct a ResourceLink from vmcp.Content fields.
return mcp.NewResourceLink(content.URI, content.Name, content.Description, content.MimeType)
default:
slog.Warn("converting unknown content type to empty text - this may cause data loss", "type", content.Type)
return mcp.NewTextContent("")
Expand Down Expand Up @@ -264,23 +269,25 @@ func ContentArrayToMap(content []vmcp.Content) map[string]any {

for _, item := range content {
switch item.Type {
case contentTypeText:
key := contentTypeText
case vmcp.ContentTypeText:
key := string(vmcp.ContentTypeText)
if textIndex > 0 {
key = fmt.Sprintf("text_%d", textIndex)
}
result[key] = item.Text
textIndex++

case contentTypeImage:
case vmcp.ContentTypeImage:
key := fmt.Sprintf("image_%d", imageIndex)
result[key] = item.Data
imageIndex++

// Default case (implicit):
case vmcp.ContentTypeAudio, vmcp.ContentTypeResource, vmcp.ContentTypeLink:
// Purposely ignored for template substitution:
// - Audio content is ignored (not supported for template substitution)
// - Resource content is ignored (handled separately, not converted to map)
// - Unknown content types are ignored (warnings logged at conversion boundaries)
// - Resource content/link is handled separately, not converted to map
default:
// Unknown content types are ignored (warnings logged at conversion boundaries)
}
}

Expand Down
Loading
Loading