Virtual MCP Server Not Preserving `_meta` Field in Responses
Summary
The Virtual MCP Server (vMCP) is not preserving the _meta field from backend MCP server responses when forwarding them to clients. According to the MCP specification, _meta is a reserved field that can appear in both requests and responses for protocol-level metadata, but our current implementation only extracts _meta from requests and discards it from responses.
Problem Description
The Model Context Protocol specification defines _meta as a universal metadata field that can appear in:
- Request params (
JSONRPCRequest.params._meta) - Response results (all
Resulttypes inherit_meta?: { [key: string]: unknown }) - Notifications (
JSONRPCNotification.params._meta) - Content blocks (
TextContent._meta,ImageContent._meta, etc.) - Entity definitions (
Tool._meta,Resource._meta,Prompt._meta, etc.)
Current Behavior:
- ✅ vMCP correctly extracts
_metafrom incoming request params (pkg/mcp/parser.go:224-230) - ✅ vMCP makes
_metaavailable via context (mcp.GetMCPMeta(ctx)) - ❌ vMCP discards
_metafrom backend responses when converting them to simplified maps - ❌ vMCP cannot restore
_metawhen constructing response objects for clients
Expected Behavior:
vMCP should preserve _meta fields from backend responses and forward them to clients, enabling:
- Progress token propagation for long-running operations
- Distributed tracing through the full request/response cycle
- Custom metadata from backends reaching clients
- Full MCP specification compliance
Root Cause Analysis
Architecture Flow
Client Request (with _meta)
↓
vMCP Server (preserves request _meta ✅)
↓
Backend MCP Server
↓
Backend Response (with _meta)
↓
vMCP Client Conversion ❌ LOSES _meta HERE
↓
vMCP Server Response (without _meta)
↓
Client receives incomplete response ❌
Code Analysis
1. Backend Response Structure (Correct)
Backends return full MCP response objects with _meta:
// From mark3labs/mcp-go SDK
type CallToolResult struct {
Content []Content // Response content
IsError bool // Error flag
StructuredContent map[string]any // Optional structured output
_meta map[string]unknown // ✅ Metadata present
}
Reference: MCP Spec - CallToolResult schema
2. Client Conversion Layer (Bug Location)
File: pkg/vmcp/client/client.go:391-476
func (h *httpBackendClient) CallTool(
ctx context.Context,
target *vmcp.BackendTarget,
toolName string,
arguments map[string]any,
) (map[string]any, error) {
// ...
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: backendToolName,
Arguments: arguments,
},
})
if err != nil {
return nil, fmt.Errorf("...")
}
// ❌ BUG: Convert result contents to simplified map, discarding _meta
resultMap := make(map[string]any)
if len(result.Content) > 0 {
for i, content := range result.Content {
if textContent, ok := mcp.AsTextContent(content); ok {
resultMap["text"] = textContent.Text
} else if imageContent, ok := mcp.AsImageContent(content); ok {
resultMap["image"] = imageContent.Data
}
// _meta from result is never copied to resultMap
}
}
return resultMap, nil // ← Returns without _meta
}
Problem: The conversion from mcp.CallToolResult to map[string]any extracts only content fields and discards the _meta field entirely.
3. Interface Definition (Design Issue)
File: pkg/vmcp/types.go:247-263
type BackendClient interface {
// CallTool invokes a tool on the backend MCP server.
// Returns the tool output or an error.
CallTool(ctx context.Context, target *BackendTarget, toolName string, arguments map[string]any) (map[string]any, error)
// ReadResource retrieves a resource from the backend MCP server.
// Returns the resource content or an error.
ReadResource(ctx context.Context, target *BackendTarget, uri string) ([]byte, error)
// GetPrompt retrieves a prompt from the backend MCP server.
// Returns the rendered prompt text or an error.
GetPrompt(ctx context.Context, target *BackendTarget, name string, arguments map[string]any) (string, error)
}
Problem: The interface returns simplified types (map[string]any, []byte, string) instead of full MCP response structures that would include _meta.
4. Handler Factory (Cannot Fix Without Upstream Data)
File: pkg/vmcp/server/adapter/handler_factory.go:71-113
func (f *DefaultHandlerFactory) CreateToolHandler(
toolName string,
) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// ...
// Call backend - receives map without _meta
result, err := f.backendClient.CallTool(ctx, target, toolName, args)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// ❌ Cannot restore _meta here - it was already lost upstream
return mcp.NewToolResultStructuredOnly(result), nil
}
}
Problem: By the time we construct the response for the client, _meta has already been discarded in the conversion layer.
Impact Assessment
Affected Operations
All MCP operations that return structured results are affected:
-
Tool Calls (
tools/call)- Missing
_metainCallToolResult - Cannot propagate progress tokens, trace IDs, or custom metadata
- Missing
-
Resource Reads (
resources/read)- Missing
_metainReadResourceResult - Cannot propagate resource-specific metadata
- Missing
-
Prompt Gets (
prompts/get)- Missing
_metainGetPromptResult - Cannot propagate prompt-related metadata
- Missing
-
List Operations (
tools/list,resources/list,prompts/list)- Missing
_metainListToolsResult,ListResourcesResult,ListPromptsResult - Cannot propagate pagination metadata, cache hints, etc.
- Missing
-
Capability Discovery (
initialize)- Missing
_metainInitializeResult - Cannot propagate server-specific initialization metadata
- Missing
MCP Specification Compliance
According to the MCP specification (2025-06-18):
The
_metaproperty/parameter is reserved by MCP to allow clients and servers to attach additional metadata to their interactions.
All Result types in the spec inherit the base Result interface:
interface Result {
_meta?: { [key: string]: unknown };
[key: string]: unknown;
}
vMCP is currently non-compliant with this aspect of the specification.
Use Cases Broken
-
Progress Reporting
- Backends cannot report progress via
progressTokenin responses - Clients cannot track long-running operations
- Backends cannot report progress via
-
Distributed Tracing
- Trace context (e.g.,
traceparent) is lost in responses - End-to-end tracing through vMCP is incomplete
- Trace context (e.g.,
-
Custom Backend Metadata
- Backend-specific metadata (caching hints, rate limits, etc.) is discarded
- Clients cannot make informed decisions based on backend state
-
MCP Tasks (November 2025 Spec)
- Task-related metadata (
io.modelcontextprotocol/related-task) must be preserved - See: Related Task Metadata
- Current implementation cannot support task association metadata in responses
- Task-related metadata (
Affected Code Locations
Core Issue
pkg/vmcp/client/client.go:391-476-CallToolconversion loses_metapkg/vmcp/client/client.go:479-534-ReadResourceconversion loses_metapkg/vmcp/client/client.go:535-596-GetPromptconversion loses_meta
Interface Definition
pkg/vmcp/types.go:247-263-BackendClientinterface returns simplified types
Handler Layer
pkg/vmcp/server/adapter/handler_factory.go:71-113-CreateToolHandlerpkg/vmcp/server/adapter/handler_factory.go:115-169-CreateResourceHandlerpkg/vmcp/server/adapter/handler_factory.go:171-244-CreatePromptHandler
Request Parsing (Working Correctly)
pkg/mcp/parser.go:224-230- Request_metaextraction ✅pkg/mcp/parser.go:478-485-GetMCPMetacontext helper ✅
Proposed Solution
Option 1: Rich Response Types (Recommended)
Change BackendClient interface to return full MCP response structures:
type BackendClient interface {
// Returns full CallToolResult including _meta
CallTool(ctx context.Context, target *BackendTarget, toolName string, arguments map[string]any) (*mcp.CallToolResult, error)
// Returns full ReadResourceResult including _meta
ReadResource(ctx context.Context, target *BackendTarget, uri string) (*mcp.ReadResourceResult, error)
// Returns full GetPromptResult including _meta
GetPrompt(ctx context.Context, target *BackendTarget, name string, arguments map[string]any) (*mcp.GetPromptResult, error)
// ListCapabilities should also return full results with _meta
ListCapabilities(ctx context.Context, target *BackendTarget) (*CapabilityList, error)
}
Advantages:
- Full MCP spec compliance
- Preserves all response metadata
- Type-safe - compiler enforces correct usage
- Easier to add support for future MCP features
Disadvantages:
- Requires updating all
BackendClientimplementations - Breaking change to internal API
Option 2: Metadata Wrapper
Add metadata wrapper around results:
type ResponseWithMeta struct {
Result map[string]any
Meta map[string]any // Preserved _meta field
}
type BackendClient interface {
CallTool(...) (*ResponseWithMeta, error)
// ...
}
Advantages:
- Smaller change than Option 1
- Preserves existing result structure
Disadvantages:
- Not type-safe for different response types
- Still requires interface changes
- Less aligned with MCP specification types
Option 3: Context-Based Metadata
Store response metadata in context:
type responseMetaKey struct{}
func SetResponseMeta(ctx context.Context, meta map[string]any) context.Context {
return context.WithValue(ctx, responseMetaKey{}, meta)
}
func GetResponseMeta(ctx context.Context) map[string]any {
if meta, ok := ctx.Value(responseMetaKey{}).(map[string]any); ok {
return meta
}
return nil
}
Advantages:
- No interface changes required
- Can be added incrementally
Disadvantages:
- Implicit - easy to forget to propagate
- Context pollution
- Not idiomatic for response data
- Difficult to track metadata flow
Recommendation
Implement Option 1 (Rich Response Types) for these reasons:
- Spec Alignment: Directly aligns with MCP specification types
- Type Safety: Compiler enforces correct metadata handling
- Future-Proof: Easy to add new MCP features (e.g., structured tool outputs)
- Clarity: Explicit response structure, no hidden context magic
- Testability: Easy to verify metadata preservation in tests
Implementation Plan
Phase 1: Interface Update
- Update
BackendClientinterface to return full MCP response types - Update
httpBackendClientimplementation to preserve_meta - Update mock implementations for tests
Phase 2: Conversion Layer
- Remove simplified conversion logic in
pkg/vmcp/client/client.go - Return full
mcp.CallToolResult,mcp.ReadResourceResult, etc. - Preserve
_metafield from backend responses
Phase 3: Handler Adaptation
- Update handler factory to use full response types
- Forward
_metafrom backend responses to client responses - Merge/combine
_metafields when aggregating multiple backends (if needed)
Phase 4: Testing
- Add tests for
_metapreservation in responses - Add integration tests with real MCP servers
- Test distributed tracing flow
- Test progress token propagation
Phase 5: Documentation
- Update architecture documentation
- Document
_metahandling in vMCP - Add examples for custom metadata
Testing Considerations
Unit Tests
func TestCallTool_PreservesMetaInResponse(t *testing.T) {
// Arrange: Backend returns response with _meta
expectedMeta := map[string]any{
"progressToken": "abc123",
"traceparent": "00-trace-id-01",
"custom.backend/hint": "cache-hit",
}
// Act: Call tool through vMCP
result, err := client.CallTool(ctx, target, "test_tool", args)
// Assert: _meta is preserved
require.NoError(t, err)
assert.Equal(t, expectedMeta, result._meta)
}
Integration Tests
func TestVMCP_EndToEndMetaPropagation(t *testing.T) {
// Test full flow: Client → vMCP → Backend → vMCP → Client
// Verify _meta propagates through entire chain
}
Compatibility Tests
func TestVMCP_BackwardCompatibility(t *testing.T) {
// Verify vMCP handles backends that don't return _meta
// (should not break, just omit _meta in response)
}
References
- MCP Specification (2025-06-18) - _meta field
- MCP Schema - Result types
- MCP Tasks - Related Task Metadata
- W3C Trace Context - Common use case for _meta
Additional Context
Current Request _meta Handling (For Comparison)
Request _meta is correctly handled:
// pkg/mcp/parser.go:224-230
var meta map[string]interface{}
if metaRaw, ok := paramsMap["_meta"]; ok {
if metaMap, ok := metaRaw.(map[string]interface{}); ok {
meta = metaMap // ✅ Preserved in context
}
}
We should apply the same preservation approach to response _meta.
MCP Specification Quote
From the spec:
Certain key names are reserved by MCP for protocol-level metadata... implementations MUST NOT make assumptions about values at these keys.
This means vMCP should act as a transparent proxy for _meta fields, preserving them without interpretation or modification (except for aggregation scenarios where merging may be needed).