diff --git a/pkg/vmcp/client/meta_integration_test.go b/pkg/vmcp/client/meta_integration_test.go index 50496e35aa..2fc1f18ed8 100644 --- a/pkg/vmcp/client/meta_integration_test.go +++ b/pkg/vmcp/client/meta_integration_test.go @@ -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) } @@ -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. diff --git a/pkg/vmcp/composer/workflow_engine_test.go b/pkg/vmcp/composer/workflow_engine_test.go index 8c4039b202..14cd529bc7 100644 --- a/pkg/vmcp/composer/workflow_engine_test.go +++ b/pkg/vmcp/composer/workflow_engine_test.go @@ -137,7 +137,7 @@ 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), @@ -145,7 +145,7 @@ func TestWorkflowEngine_ExecuteWorkflow_IsErrorHandling(t *testing.T) { Return(&vmcp.ToolCallResult{ IsError: true, Content: []vmcp.Content{{ - Type: "text", + Type: vmcp.ContentTypeText, Text: "Tool execution failed: temporary error", }}, }, nil), @@ -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 diff --git a/pkg/vmcp/conversion/content.go b/pkg/vmcp/conversion/content.go index b1bff10e18..a2ac604b3c 100644 --- a/pkg/vmcp/conversion/content.go +++ b/pkg/vmcp/conversion/content.go @@ -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"} @@ -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 != "" { @@ -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("") @@ -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) } } diff --git a/pkg/vmcp/conversion/conversion_test.go b/pkg/vmcp/conversion/conversion_test.go index ef0aac60a4..017f222ee7 100644 --- a/pkg/vmcp/conversion/conversion_test.go +++ b/pkg/vmcp/conversion/conversion_test.go @@ -173,17 +173,17 @@ func TestConvertMCPContent(t *testing.T) { { name: "text content", input: mcp.NewTextContent("hello world"), - want: vmcp.Content{Type: "text", Text: "hello world"}, + want: vmcp.Content{Type: vmcp.ContentTypeText, Text: "hello world"}, }, { name: "image content", input: mcp.NewImageContent("base64imgdata", "image/png"), - want: vmcp.Content{Type: "image", Data: "base64imgdata", MimeType: "image/png"}, + want: vmcp.Content{Type: vmcp.ContentTypeImage, Data: "base64imgdata", MimeType: "image/png"}, }, { name: "audio content", input: mcp.NewAudioContent("base64audiodata", "audio/mpeg"), - want: vmcp.Content{Type: "audio", Data: "base64audiodata", MimeType: "audio/mpeg"}, + want: vmcp.Content{Type: vmcp.ContentTypeAudio, Data: "base64audiodata", MimeType: "audio/mpeg"}, }, { name: "embedded resource with text content", @@ -192,7 +192,7 @@ func TestConvertMCPContent(t *testing.T) { MIMEType: "text/markdown", Text: "# Hello World", }), - want: vmcp.Content{Type: "resource", Text: "# Hello World", URI: "file://readme.md", MimeType: "text/markdown"}, + want: vmcp.Content{Type: vmcp.ContentTypeResource, Text: "# Hello World", URI: "file://readme.md", MimeType: "text/markdown"}, }, { name: "embedded resource with blob content", @@ -201,14 +201,30 @@ func TestConvertMCPContent(t *testing.T) { MIMEType: "image/png", Blob: "base64blobdata", }), - want: vmcp.Content{Type: "resource", Data: "base64blobdata", URI: "file://image.png", MimeType: "image/png"}, + want: vmcp.Content{Type: vmcp.ContentTypeResource, Data: "base64blobdata", URI: "file://image.png", MimeType: "image/png"}, }, { name: "embedded resource with empty URI and MimeType", input: mcp.NewEmbeddedResource(mcp.TextResourceContents{ Text: "content only", }), - want: vmcp.Content{Type: "resource", Text: "content only"}, + want: vmcp.Content{Type: vmcp.ContentTypeResource, Text: "content only"}, + }, + { + name: "resource_link with all fields", + input: mcp.NewResourceLink("file://doc.pdf", "My Doc", "A PDF document", "application/pdf"), + want: vmcp.Content{ + Type: vmcp.ContentTypeLink, + URI: "file://doc.pdf", + Name: "My Doc", + Description: "A PDF document", + MimeType: "application/pdf", + }, + }, + { + name: "resource_link with empty optional fields", + input: mcp.NewResourceLink("file://x", "X", "", ""), + want: vmcp.Content{Type: vmcp.ContentTypeLink, URI: "file://x", Name: "X"}, }, } @@ -244,9 +260,9 @@ func TestConvertMCPContents(t *testing.T) { mcp.NewAudioContent("audiodata", "audio/ogg"), } want := []vmcp.Content{ - {Type: "text", Text: "first"}, - {Type: "image", Data: "imgdata", MimeType: "image/jpeg"}, - {Type: "audio", Data: "audiodata", MimeType: "audio/ogg"}, + {Type: vmcp.ContentTypeText, Text: "first"}, + {Type: vmcp.ContentTypeImage, Data: "imgdata", MimeType: "image/jpeg"}, + {Type: vmcp.ContentTypeAudio, Data: "audiodata", MimeType: "audio/ogg"}, } got := conversion.ConvertMCPContents(input) assert.Equal(t, want, got) @@ -356,7 +372,7 @@ func TestContentArrayToMap(t *testing.T) { { name: "single text content", content: []vmcp.Content{ - {Type: "text", Text: "Hello, world!"}, + {Type: vmcp.ContentTypeText, Text: "Hello, world!"}, }, expected: map[string]any{ "text": "Hello, world!", @@ -365,9 +381,9 @@ func TestContentArrayToMap(t *testing.T) { { name: "multiple text contents", content: []vmcp.Content{ - {Type: "text", Text: "First"}, - {Type: "text", Text: "Second"}, - {Type: "text", Text: "Third"}, + {Type: vmcp.ContentTypeText, Text: "First"}, + {Type: vmcp.ContentTypeText, Text: "Second"}, + {Type: vmcp.ContentTypeText, Text: "Third"}, }, expected: map[string]any{ "text": "First", @@ -378,7 +394,7 @@ func TestContentArrayToMap(t *testing.T) { { name: "single image content", content: []vmcp.Content{ - {Type: "image", Data: "base64data", MimeType: "image/png"}, + {Type: vmcp.ContentTypeImage, Data: "base64data", MimeType: "image/png"}, }, expected: map[string]any{ "image_0": "base64data", @@ -387,8 +403,8 @@ func TestContentArrayToMap(t *testing.T) { { name: "multiple images", content: []vmcp.Content{ - {Type: "image", Data: "data1", MimeType: "image/png"}, - {Type: "image", Data: "data2", MimeType: "image/jpeg"}, + {Type: vmcp.ContentTypeImage, Data: "data1", MimeType: "image/png"}, + {Type: vmcp.ContentTypeImage, Data: "data2", MimeType: "image/jpeg"}, }, expected: map[string]any{ "image_0": "data1", @@ -398,10 +414,10 @@ func TestContentArrayToMap(t *testing.T) { { name: "mixed content types", content: []vmcp.Content{ - {Type: "text", Text: "First text"}, - {Type: "image", Data: "image1", MimeType: "image/png"}, - {Type: "text", Text: "Second text"}, - {Type: "image", Data: "image2", MimeType: "image/jpeg"}, + {Type: vmcp.ContentTypeText, Text: "First text"}, + {Type: vmcp.ContentTypeImage, Data: "image1", MimeType: "image/png"}, + {Type: vmcp.ContentTypeText, Text: "Second text"}, + {Type: vmcp.ContentTypeImage, Data: "image2", MimeType: "image/jpeg"}, }, expected: map[string]any{ "text": "First text", @@ -413,16 +429,16 @@ func TestContentArrayToMap(t *testing.T) { { name: "audio content is ignored", content: []vmcp.Content{ - {Type: "audio", Data: "audiodata", MimeType: "audio/mpeg"}, + {Type: vmcp.ContentTypeAudio, Data: "audiodata", MimeType: "audio/mpeg"}, }, expected: map[string]any{}, }, { name: "audio mixed with other content is ignored", content: []vmcp.Content{ - {Type: "text", Text: "Text content"}, - {Type: "audio", Data: "audiodata", MimeType: "audio/mpeg"}, - {Type: "image", Data: "imagedata", MimeType: "image/png"}, + {Type: vmcp.ContentTypeText, Text: "Text content"}, + {Type: vmcp.ContentTypeAudio, Data: "audiodata", MimeType: "audio/mpeg"}, + {Type: vmcp.ContentTypeImage, Data: "imagedata", MimeType: "image/png"}, }, expected: map[string]any{ "text": "Text content", @@ -432,9 +448,9 @@ func TestContentArrayToMap(t *testing.T) { { name: "unknown types are ignored", content: []vmcp.Content{ - {Type: "text", Text: "Text"}, + {Type: vmcp.ContentTypeText, Text: "Text"}, {Type: "unknown", Text: "Should be ignored"}, - {Type: "resource", URI: "file://test"}, + {Type: vmcp.ContentTypeResource, URI: "file://test"}, }, expected: map[string]any{ "text": "Text", @@ -443,18 +459,18 @@ func TestContentArrayToMap(t *testing.T) { { name: "handles 10+ text items correctly", content: []vmcp.Content{ - {Type: "text", Text: "0"}, - {Type: "text", Text: "1"}, - {Type: "text", Text: "2"}, - {Type: "text", Text: "3"}, - {Type: "text", Text: "4"}, - {Type: "text", Text: "5"}, - {Type: "text", Text: "6"}, - {Type: "text", Text: "7"}, - {Type: "text", Text: "8"}, - {Type: "text", Text: "9"}, - {Type: "text", Text: "10"}, - {Type: "text", Text: "11"}, + {Type: vmcp.ContentTypeText, Text: "0"}, + {Type: vmcp.ContentTypeText, Text: "1"}, + {Type: vmcp.ContentTypeText, Text: "2"}, + {Type: vmcp.ContentTypeText, Text: "3"}, + {Type: vmcp.ContentTypeText, Text: "4"}, + {Type: vmcp.ContentTypeText, Text: "5"}, + {Type: vmcp.ContentTypeText, Text: "6"}, + {Type: vmcp.ContentTypeText, Text: "7"}, + {Type: vmcp.ContentTypeText, Text: "8"}, + {Type: vmcp.ContentTypeText, Text: "9"}, + {Type: vmcp.ContentTypeText, Text: "10"}, + {Type: vmcp.ContentTypeText, Text: "11"}, }, expected: map[string]any{ "text": "0", @@ -734,32 +750,32 @@ func TestToMCPContent(t *testing.T) { }{ { name: "text content", - input: vmcp.Content{Type: "text", Text: "Hello, world!"}, + input: vmcp.Content{Type: vmcp.ContentTypeText, Text: "Hello, world!"}, wantType: "mcp.TextContent", wantText: "Hello, world!", }, { name: "empty text content", - input: vmcp.Content{Type: "text", Text: ""}, + input: vmcp.Content{Type: vmcp.ContentTypeText, Text: ""}, wantType: "mcp.TextContent", }, { name: "image content", - input: vmcp.Content{Type: "image", Data: "base64data", MimeType: "image/png"}, + input: vmcp.Content{Type: vmcp.ContentTypeImage, Data: "base64data", MimeType: "image/png"}, wantType: "mcp.ImageContent", wantData: "base64data", wantMime: "image/png", }, { name: "audio content", - input: vmcp.Content{Type: "audio", Data: "audiodata", MimeType: "audio/mpeg"}, + input: vmcp.Content{Type: vmcp.ContentTypeAudio, Data: "audiodata", MimeType: "audio/mpeg"}, wantType: "mcp.AudioContent", wantData: "audiodata", wantMime: "audio/mpeg", }, { name: "text resource content", - input: vmcp.Content{Type: "resource", Text: "# README", URI: "file://readme.md", MimeType: "text/markdown"}, + input: vmcp.Content{Type: vmcp.ContentTypeResource, Text: "# README", URI: "file://readme.md", MimeType: "text/markdown"}, wantType: "mcp.EmbeddedResource", wantText: "# README", wantURI: "file://readme.md", @@ -767,7 +783,7 @@ func TestToMCPContent(t *testing.T) { }, { name: "blob resource content", - input: vmcp.Content{Type: "resource", Data: "base64blob", URI: "file://image.png", MimeType: "image/png"}, + input: vmcp.Content{Type: vmcp.ContentTypeResource, Data: "base64blob", URI: "file://image.png", MimeType: "image/png"}, wantType: "mcp.EmbeddedResource", wantData: "base64blob", wantURI: "file://image.png", @@ -775,7 +791,7 @@ func TestToMCPContent(t *testing.T) { }, { name: "empty resource content preserves resource type", - input: vmcp.Content{Type: "resource"}, + input: vmcp.Content{Type: vmcp.ContentTypeResource}, wantType: "mcp.EmbeddedResource", wantText: "", // Empty text but still an EmbeddedResource }, @@ -784,6 +800,24 @@ func TestToMCPContent(t *testing.T) { input: vmcp.Content{Type: "custom-type"}, wantType: "mcp.TextContent", }, + { + name: "resource_link content all fields", + input: vmcp.Content{ + Type: vmcp.ContentTypeLink, + URI: "file://doc.pdf", + Name: "My Doc", + Description: "A PDF document", + MimeType: "application/pdf", + }, + wantType: "mcp.ResourceLink", + wantURI: "file://doc.pdf", + wantMime: "application/pdf", + }, + { + name: "resource_link with empty fields", + input: vmcp.Content{Type: vmcp.ContentTypeLink}, + wantType: "mcp.ResourceLink", + }, } for _, tt := range tests { @@ -824,6 +858,13 @@ func TestToMCPContent(t *testing.T) { assert.Equal(t, tt.wantURI, blobRes.URI) assert.Equal(t, tt.wantMime, blobRes.MIMEType) } + case "mcp.ResourceLink": + link, ok := result.(mcp.ResourceLink) + require.True(t, ok, "expected ResourceLink") + assert.Equal(t, tt.wantURI, link.URI) + assert.Equal(t, tt.wantMime, link.MIMEType) + assert.Equal(t, tt.input.Name, link.Name) + assert.Equal(t, tt.input.Description, link.Description) default: t.Errorf("unexpected wantType: %s", tt.wantType) } @@ -893,3 +934,46 @@ func TestResourceContentRoundTrip(t *testing.T) { }) } } + +func TestResourceLinkRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + initial mcp.ResourceLink + }{ + { + name: "resource_link with all fields preserved", + initial: mcp.NewResourceLink("file://doc.pdf", "My Doc", "A PDF document", "application/pdf"), + }, + { + name: "resource_link with empty optional fields preserved", + initial: mcp.NewResourceLink("file://x", "X", "", ""), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Convert mcp.ResourceLink → vmcp.Content + vmcpContent := conversion.ConvertMCPContent(tt.initial) + + assert.Equal(t, vmcp.ContentTypeLink, vmcpContent.Type) + assert.Equal(t, tt.initial.URI, vmcpContent.URI) + assert.Equal(t, tt.initial.Name, vmcpContent.Name) + assert.Equal(t, tt.initial.Description, vmcpContent.Description) + assert.Equal(t, tt.initial.MIMEType, vmcpContent.MimeType) + + // Convert vmcp.Content → mcp.Content + mcpContent := conversion.ToMCPContent(vmcpContent) + + finalLink, ok := mcpContent.(mcp.ResourceLink) + require.True(t, ok, "round-trip result should be ResourceLink, got %T", mcpContent) + assert.Equal(t, tt.initial.URI, finalLink.URI, "URI should be preserved") + assert.Equal(t, tt.initial.Name, finalLink.Name, "Name should be preserved") + assert.Equal(t, tt.initial.Description, finalLink.Description, "Description should be preserved") + assert.Equal(t, tt.initial.MIMEType, finalLink.MIMEType, "MIMEType should be preserved") + }) + } +} diff --git a/pkg/vmcp/server/adapter/handler_factory_test.go b/pkg/vmcp/server/adapter/handler_factory_test.go index 03407d775c..2dd1d0b27c 100644 --- a/pkg/vmcp/server/adapter/handler_factory_test.go +++ b/pkg/vmcp/server/adapter/handler_factory_test.go @@ -177,7 +177,7 @@ func TestDefaultHandlerFactory_CreateToolHandler(t *testing.T) { CallTool(gomock.Any(), target, "test_tool", map[string]any{"input": "test"}, gomock.Any()). Return(&vmcp.ToolCallResult{ Content: []vmcp.Content{ - {Type: "text", Text: "tool execution failed"}, + {Type: vmcp.ContentTypeText, Text: "tool execution failed"}, }, IsError: true, }, nil) diff --git a/pkg/vmcp/server/sessionmanager/session_manager_test.go b/pkg/vmcp/server/sessionmanager/session_manager_test.go index 481a542721..7a809b0d49 100644 --- a/pkg/vmcp/server/sessionmanager/session_manager_test.go +++ b/pkg/vmcp/server/sessionmanager/session_manager_test.go @@ -868,7 +868,7 @@ func TestSessionManager_GetAdaptedTools(t *testing.T) { ctrl := gomock.NewController(t) callToolResult := &vmcp.ToolCallResult{ - Content: []vmcp.Content{{Type: "text", Text: "Hello, world!"}}, + Content: []vmcp.Content{{Type: vmcp.ContentTypeText, Text: "Hello, world!"}}, } factory := sessionfactorymocks.NewMockMultiSessionFactory(ctrl) factory.EXPECT(). diff --git a/pkg/vmcp/session/default_session_test.go b/pkg/vmcp/session/default_session_test.go index 9cd5838e26..d31c38b174 100644 --- a/pkg/vmcp/session/default_session_test.go +++ b/pkg/vmcp/session/default_session_test.go @@ -40,7 +40,7 @@ func (m *mockConnectedBackend) CallTool(ctx context.Context, toolName string, ar if m.callToolFunc != nil { return m.callToolFunc(ctx, toolName, arguments, meta) } - return &vmcp.ToolCallResult{Content: []vmcp.Content{{Type: "text", Text: "ok"}}}, nil + return &vmcp.ToolCallResult{Content: []vmcp.Content{{Type: vmcp.ContentTypeText, Text: "ok"}}}, nil } func (m *mockConnectedBackend) ReadResource(ctx context.Context, uri string) (*vmcp.ResourceReadResult, error) { @@ -156,7 +156,7 @@ func TestDefaultSession_CallTool(t *testing.T) { name: "successful tool call", toolName: "search", mockFn: func(_ context.Context, _ string, _, _ map[string]any) (*vmcp.ToolCallResult, error) { - return &vmcp.ToolCallResult{Content: []vmcp.Content{{Type: "text", Text: "result"}}}, nil + return &vmcp.ToolCallResult{Content: []vmcp.Content{{Type: vmcp.ContentTypeText, Text: "result"}}}, nil }, wantContent: "result", }, diff --git a/pkg/vmcp/types.go b/pkg/vmcp/types.go index 9733dab8e4..2a525691d9 100644 --- a/pkg/vmcp/types.go +++ b/pkg/vmcp/types.go @@ -364,11 +364,27 @@ type PromptArgument struct { Required bool } -// Content represents MCP content (text, image, audio, embedded resource). +// ContentType represents the type of content in an MCP message. +type ContentType string + +const ( + // ContentTypeText represents text content. + ContentTypeText ContentType = "text" + // ContentTypeImage represents image content. + ContentTypeImage ContentType = "image" + // ContentTypeAudio represents audio content. + ContentTypeAudio ContentType = "audio" + // ContentTypeResource represents embedded resource content. + ContentTypeResource ContentType = "resource" + // ContentTypeLink represents a resource link. + ContentTypeLink ContentType = "resource_link" +) + +// Content represents MCP content (text, image, audio, embedded resource, resource link). // This is used by ToolCallResult to preserve the full content structure from backends. type Content struct { - // Type indicates the content type: "text", "image", "audio", "resource" - Type string + // Type indicates the content type. + Type ContentType // Text is the content text (for TextContent) Text string @@ -376,11 +392,17 @@ type Content struct { // Data is the base64-encoded data (for ImageContent/AudioContent) Data string - // MimeType is the MIME type (for ImageContent/AudioContent) + // MimeType is the MIME type (for ImageContent/AudioContent/ResourceLink) MimeType string - // URI is the resource URI (for EmbeddedResource) + // URI is the resource URI (for EmbeddedResource/ResourceLink) URI string + + // Name is the resource name (for ResourceLink) + Name string + + // Description is the resource description (for ResourceLink) + Description string } // ToolCallResult wraps a tool call response with metadata.