Skip to content

Commit 2c4ff03

Browse files
committed
fixes from review
1 parent d7e0898 commit 2c4ff03

File tree

11 files changed

+935
-97
lines changed

11 files changed

+935
-97
lines changed

pkg/vmcp/client/client.go

Lines changed: 24 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/stacklok/toolhive/pkg/vmcp"
2626
vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth"
2727
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
28+
"github.com/stacklok/toolhive/pkg/vmcp/conversion"
2829
)
2930

3031
const (
@@ -372,34 +373,6 @@ func queryPrompts(ctx context.Context, c *client.Client, supported bool, backend
372373
return &mcp.ListPromptsResult{Prompts: []mcp.Prompt{}}, nil
373374
}
374375

375-
// extractMeta converts mcp.Meta to map[string]any for vmcp wrapper types.
376-
// This preserves the _meta field from backend MCP server responses.
377-
// Returns nil if meta is nil or empty.
378-
func extractMeta(meta *mcp.Meta) map[string]any {
379-
if meta == nil {
380-
return nil
381-
}
382-
383-
result := make(map[string]any)
384-
385-
// Add progressToken if present
386-
if meta.ProgressToken != nil {
387-
result["progressToken"] = meta.ProgressToken
388-
}
389-
390-
// Merge additional fields (custom metadata like trace context)
391-
for k, v := range meta.AdditionalFields {
392-
result[k] = v
393-
}
394-
395-
// Return nil if the map is empty (no metadata to preserve)
396-
if len(result) == 0 {
397-
return nil
398-
}
399-
400-
return result
401-
}
402-
403376
// convertContent converts mcp.Content to vmcp.Content.
404377
// This preserves the full content structure from backend responses.
405378
func convertContent(content mcp.Content) vmcp.Content {
@@ -416,8 +389,17 @@ func convertContent(content mcp.Content) vmcp.Content {
416389
MimeType: imageContent.MIMEType,
417390
}
418391
}
392+
if audioContent, ok := mcp.AsAudioContent(content); ok {
393+
return vmcp.Content{
394+
Type: "audio",
395+
Data: audioContent.Data,
396+
MimeType: audioContent.MIMEType,
397+
}
398+
}
419399
// Handle embedded resources if needed
420400
// For now, unknown types return text with empty content
401+
logger.Warnf("Encountered unknown content type %T, converting to empty content. "+
402+
"This may indicate missing support for embedded resources or other MCP content types.", content)
421403
return vmcp.Content{Type: "unknown"}
422404
}
423405

@@ -582,6 +564,9 @@ func (h *httpBackendClient) CallTool(
582564
return nil, fmt.Errorf("%w: tool call failed on backend %s: %w", vmcp.ErrBackendUnavailable, target.WorkloadID, err)
583565
}
584566

567+
// Extract _meta field from backend response early for error logging
568+
meta := conversion.FromMCPMeta(result.Meta)
569+
585570
// Check if the tool call returned an error (MCP domain error)
586571
if result.IsError {
587572
// Extract error message from content for logging and forwarding
@@ -594,7 +579,14 @@ func (h *httpBackendClient) CallTool(
594579
if errorMsg == "" {
595580
errorMsg = "unknown error"
596581
}
597-
logger.Warnf("Tool %s on backend %s returned error: %s", toolName, target.WorkloadID, errorMsg)
582+
583+
// Include _meta in error log for distributed tracing and debugging
584+
if meta != nil {
585+
logger.Warnf("Tool %s on backend %s returned error: %s (meta: %+v)", toolName, target.WorkloadID, errorMsg, meta)
586+
} else {
587+
logger.Warnf("Tool %s on backend %s returned error: %s", toolName, target.WorkloadID, errorMsg)
588+
}
589+
598590
// Wrap with ErrToolExecutionFailed so router can forward transparently to client
599591
return nil, fmt.Errorf("%w: %s on backend %s: %s", vmcp.ErrToolExecutionFailed, toolName, target.WorkloadID, errorMsg)
600592
}
@@ -605,9 +597,6 @@ func (h *httpBackendClient) CallTool(
605597
contentArray[i] = convertContent(content)
606598
}
607599

608-
// Extract _meta field from backend response
609-
meta := extractMeta(result.Meta)
610-
611600
// Check for structured content first (preferred for composite tool step chaining).
612601
// StructuredContent allows templates to access nested fields directly via {{.steps.stepID.output.field}}.
613602
// Note: StructuredContent must be an object (map). Arrays or primitives are not supported.
@@ -627,31 +616,7 @@ func (h *httpBackendClient) CallTool(
627616
// MCP tools return an array of Content interface (TextContent, ImageContent, etc.).
628617
// Text content is stored under "text" key, accessible via {{.steps.stepID.output.text}}.
629618
if structuredContent == nil {
630-
structuredContent = make(map[string]any)
631-
if len(result.Content) > 0 {
632-
textIndex := 0
633-
imageIndex := 0
634-
for i, content := range result.Content {
635-
// Try to convert to TextContent
636-
if textContent, ok := mcp.AsTextContent(content); ok {
637-
key := "text"
638-
if textIndex > 0 {
639-
key = fmt.Sprintf("text_%d", textIndex)
640-
}
641-
structuredContent[key] = textContent.Text
642-
textIndex++
643-
} else if imageContent, ok := mcp.AsImageContent(content); ok {
644-
// Convert to ImageContent
645-
key := fmt.Sprintf("image_%d", imageIndex)
646-
structuredContent[key] = imageContent.Data
647-
imageIndex++
648-
} else {
649-
// Log unsupported content types for tracking
650-
logger.Debugf("Unsupported content type at index %d from tool %s on backend %s: %T",
651-
i, toolName, target.WorkloadID, content)
652-
}
653-
}
654-
}
619+
structuredContent = conversion.ContentArrayToMap(contentArray)
655620
}
656621

657622
return &vmcp.ToolCallResult{
@@ -732,7 +697,7 @@ func (h *httpBackendClient) ReadResource(
732697
// Extract _meta field from backend response
733698
// Note: Due to MCP SDK limitations, the SDK's ReadResourceResult may not include Meta.
734699
// This preserves it for future SDK improvements.
735-
meta := extractMeta(result.Meta)
700+
meta := conversion.FromMCPMeta(result.Meta)
736701

737702
return &vmcp.ResourceReadResult{
738703
Contents: data,
@@ -805,7 +770,7 @@ func (h *httpBackendClient) GetPrompt(
805770
}
806771

807772
// Extract _meta field from backend response
808-
meta := extractMeta(result.Meta)
773+
meta := conversion.FromMCPMeta(result.Meta)
809774

810775
return &vmcp.PromptGetResult{
811776
Messages: prompt,

pkg/vmcp/client/conversions_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,76 @@ func TestPromptArgumentConversion(t *testing.T) {
414414
assert.False(t, vmcpPrompt.Arguments[1].Required)
415415
})
416416
}
417+
418+
func TestConvertContent(t *testing.T) {
419+
t.Parallel()
420+
421+
t.Run("converts text content", func(t *testing.T) {
422+
t.Parallel()
423+
424+
mcpContent := mcp.NewTextContent("Hello, world!")
425+
result := convertContent(mcpContent)
426+
427+
assert.Equal(t, "text", result.Type)
428+
assert.Equal(t, "Hello, world!", result.Text)
429+
})
430+
431+
t.Run("converts empty text content", func(t *testing.T) {
432+
t.Parallel()
433+
434+
mcpContent := mcp.NewTextContent("")
435+
result := convertContent(mcpContent)
436+
437+
assert.Equal(t, "text", result.Type)
438+
assert.Equal(t, "", result.Text)
439+
})
440+
441+
t.Run("converts image content", func(t *testing.T) {
442+
t.Parallel()
443+
444+
mcpContent := mcp.NewImageContent("base64-encoded-image-data", "image/png")
445+
result := convertContent(mcpContent)
446+
447+
assert.Equal(t, "image", result.Type)
448+
assert.Equal(t, "base64-encoded-image-data", result.Data)
449+
assert.Equal(t, "image/png", result.MimeType)
450+
})
451+
452+
t.Run("converts image content with empty mime type", func(t *testing.T) {
453+
t.Parallel()
454+
455+
mcpContent := mcp.NewImageContent("image-data", "")
456+
result := convertContent(mcpContent)
457+
458+
assert.Equal(t, "image", result.Type)
459+
assert.Equal(t, "image-data", result.Data)
460+
assert.Equal(t, "", result.MimeType)
461+
})
462+
463+
t.Run("converts audio content", func(t *testing.T) {
464+
t.Parallel()
465+
466+
mcpContent := mcp.NewAudioContent("base64-encoded-audio-data", "audio/mpeg")
467+
result := convertContent(mcpContent)
468+
469+
assert.Equal(t, "audio", result.Type)
470+
assert.Equal(t, "base64-encoded-audio-data", result.Data)
471+
assert.Equal(t, "audio/mpeg", result.MimeType)
472+
})
473+
474+
t.Run("converts audio content with empty mime type", func(t *testing.T) {
475+
t.Parallel()
476+
477+
mcpContent := mcp.NewAudioContent("audio-data", "")
478+
result := convertContent(mcpContent)
479+
480+
assert.Equal(t, "audio", result.Type)
481+
assert.Equal(t, "audio-data", result.Data)
482+
assert.Equal(t, "", result.MimeType)
483+
})
484+
485+
// Note: We cannot easily test "unknown" content types because mcp.Content is an interface
486+
// with an isContent() marker method. The MCP SDK only provides Text, Image, and Audio content types.
487+
// If the SDK adds new content types in the future (e.g., embedded resources), convertContent
488+
// will return Type: "unknown" for those until we add explicit support.
489+
}

pkg/vmcp/client/meta_integration_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,45 @@ func TestMetaPreservation_CallTool_NoMeta(t *testing.T) {
111111
assert.Equal(t, "text", result.Content[0].Type)
112112
}
113113

114+
// TestMetaPreservation_CallTool_Error tests that _meta is logged when a tool returns an error.
115+
// This verifies that trace IDs and other metadata are included in error logs for debugging.
116+
func TestMetaPreservation_CallTool_Error(t *testing.T) {
117+
t.Parallel()
118+
119+
port, cleanup := startTestMCPServer(t)
120+
defer cleanup()
121+
122+
registry := auth.NewDefaultOutgoingAuthRegistry()
123+
err := registry.RegisterStrategy("unauthenticated", &strategies.UnauthenticatedStrategy{})
124+
require.NoError(t, err)
125+
126+
backendClient, err := vmcpclient.NewHTTPBackendClient(registry)
127+
require.NoError(t, err)
128+
129+
target := &vmcp.BackendTarget{
130+
WorkloadID: "test-backend",
131+
WorkloadName: "Test Backend",
132+
BaseURL: "http://127.0.0.1:" + port,
133+
TransportType: "streamable-http",
134+
}
135+
136+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
137+
defer cancel()
138+
139+
// Call tool that returns an error with _meta
140+
result, err := backendClient.CallTool(ctx, target, "test_tool_error", map[string]any{
141+
"input": "trigger-error",
142+
})
143+
144+
// Should return error
145+
require.Error(t, err)
146+
require.Nil(t, result)
147+
148+
// The error log should have included the _meta field (trace ID, etc.)
149+
// This is verified by checking the log output manually or in log aggregation systems
150+
// The test confirms the error path executes correctly
151+
}
152+
114153
// TestMetaPreservation_GetPrompt tests that _meta fields are preserved when getting prompts.
115154
func TestMetaPreservation_GetPrompt(t *testing.T) {
116155
t.Parallel()
@@ -152,6 +191,58 @@ func TestMetaPreservation_GetPrompt(t *testing.T) {
152191
assert.Contains(t, result.Messages, "Hello, World!")
153192
}
154193

194+
// TestMetaPreservation_ReadResource documents the SDK limitation for resource _meta.
195+
//
196+
// KNOWN LIMITATION: Due to MCP SDK constraints, resource handlers return []ResourceContents
197+
// directly, not *ReadResourceResult with _meta. This prevents backends from including _meta
198+
// in resource responses at all.
199+
//
200+
// As a result:
201+
// - Backend MCP servers cannot include _meta in resource read responses (SDK limitation)
202+
// - vMCP client cannot extract _meta because it's not in the response
203+
// - vMCP handler cannot forward _meta to clients
204+
//
205+
// This test documents the expected behavior and ensures resource reads work correctly
206+
// even though _meta is not supported. Once the SDK adds _meta support for resource handlers,
207+
// this test can be updated to verify _meta preservation.
208+
func TestMetaPreservation_ReadResource(t *testing.T) {
209+
t.Parallel()
210+
211+
port, cleanup := startTestMCPServer(t)
212+
defer cleanup()
213+
214+
registry := auth.NewDefaultOutgoingAuthRegistry()
215+
err := registry.RegisterStrategy("unauthenticated", &strategies.UnauthenticatedStrategy{})
216+
require.NoError(t, err)
217+
218+
backendClient, err := vmcpclient.NewHTTPBackendClient(registry)
219+
require.NoError(t, err)
220+
221+
target := &vmcp.BackendTarget{
222+
WorkloadID: "test-backend",
223+
WorkloadName: "Test Backend",
224+
BaseURL: "http://127.0.0.1:" + port,
225+
TransportType: "streamable-http",
226+
}
227+
228+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
229+
defer cancel()
230+
231+
// Read resource through vMCP backend client
232+
result, err := backendClient.ReadResource(ctx, target, "test://resource")
233+
234+
require.NoError(t, err)
235+
require.NotNil(t, result)
236+
237+
// Verify _meta is NOT present due to SDK limitation
238+
// The SDK handler signature doesn't support returning _meta with resources
239+
assert.Nil(t, result.Meta, "_meta cannot be included due to SDK limitation - handler returns []ResourceContents without _meta wrapper")
240+
241+
// Verify resource content works correctly
242+
assert.Equal(t, "Test resource content", string(result.Contents))
243+
assert.Equal(t, "text/plain", result.MimeType)
244+
}
245+
155246
// startTestMCPServer creates and starts a test MCP server with tools that return _meta.
156247
// Returns the port and cleanup function.
157248
func startTestMCPServer(t *testing.T) (string, func()) {
@@ -199,6 +290,31 @@ func startTestMCPServer(t *testing.T) (string, func()) {
199290
},
200291
)
201292

293+
// Add tool that returns error with _meta (for error logging test)
294+
mcpServer.AddTool(
295+
mcp.NewTool("test_tool_error",
296+
mcp.WithDescription("Test tool that returns error with metadata"),
297+
mcp.WithString("input", mcp.Required()),
298+
),
299+
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
300+
return &mcp.CallToolResult{
301+
Result: mcp.Result{
302+
Meta: &mcp.Meta{
303+
ProgressToken: "error-token-999",
304+
AdditionalFields: map[string]any{
305+
"traceId": "error-trace-abc123",
306+
"requestId": "req-error-xyz789",
307+
},
308+
},
309+
},
310+
IsError: true,
311+
Content: []mcp.Content{
312+
mcp.NewTextContent("Tool execution failed: invalid input"),
313+
},
314+
}, nil
315+
},
316+
)
317+
202318
// Add prompt that returns _meta
203319
mcpServer.AddPrompt(
204320
mcp.NewPrompt("test_prompt_with_meta",
@@ -228,6 +344,27 @@ func startTestMCPServer(t *testing.T) (string, func()) {
228344
},
229345
)
230346

347+
// Add resource that returns _meta
348+
mcpServer.AddResource(
349+
mcp.Resource{
350+
URI: "test://resource",
351+
Name: "Test Resource",
352+
Description: "Test resource with metadata",
353+
MIMEType: "text/plain",
354+
},
355+
func(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
356+
// Note: The handler returns []ResourceContents, not *ReadResourceResult
357+
// This is why _meta cannot be forwarded - SDK limitation
358+
return []mcp.ResourceContents{
359+
mcp.TextResourceContents{
360+
URI: "test://resource",
361+
MIMEType: "text/plain",
362+
Text: "Test resource content",
363+
},
364+
}, nil
365+
},
366+
)
367+
231368
// Create HTTP handler for the MCP server
232369
httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
233370
// MCP over HTTP uses POST requests with JSON-RPC

0 commit comments

Comments
 (0)