toolhive icon indicating copy to clipboard operation
toolhive copied to clipboard

Virtual MCP Server Not Preserving `_meta` Field in Responses

Open JAORMX opened this issue 1 month ago • 0 comments

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 Result types 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 _meta from incoming request params (pkg/mcp/parser.go:224-230)
  • ✅ vMCP makes _meta available via context (mcp.GetMCPMeta(ctx))
  • ❌ vMCP discards _meta from backend responses when converting them to simplified maps
  • ❌ vMCP cannot restore _meta when 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:

  1. Tool Calls (tools/call)

    • Missing _meta in CallToolResult
    • Cannot propagate progress tokens, trace IDs, or custom metadata
  2. Resource Reads (resources/read)

    • Missing _meta in ReadResourceResult
    • Cannot propagate resource-specific metadata
  3. Prompt Gets (prompts/get)

    • Missing _meta in GetPromptResult
    • Cannot propagate prompt-related metadata
  4. List Operations (tools/list, resources/list, prompts/list)

    • Missing _meta in ListToolsResult, ListResourcesResult, ListPromptsResult
    • Cannot propagate pagination metadata, cache hints, etc.
  5. Capability Discovery (initialize)

    • Missing _meta in InitializeResult
    • Cannot propagate server-specific initialization metadata

MCP Specification Compliance

According to the MCP specification (2025-06-18):

The _meta property/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

  1. Progress Reporting

    • Backends cannot report progress via progressToken in responses
    • Clients cannot track long-running operations
  2. Distributed Tracing

    • Trace context (e.g., traceparent) is lost in responses
    • End-to-end tracing through vMCP is incomplete
  3. Custom Backend Metadata

    • Backend-specific metadata (caching hints, rate limits, etc.) is discarded
    • Clients cannot make informed decisions based on backend state
  4. 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

Affected Code Locations

Core Issue

  • pkg/vmcp/client/client.go:391-476 - CallTool conversion loses _meta
  • pkg/vmcp/client/client.go:479-534 - ReadResource conversion loses _meta
  • pkg/vmcp/client/client.go:535-596 - GetPrompt conversion loses _meta

Interface Definition

  • pkg/vmcp/types.go:247-263 - BackendClient interface returns simplified types

Handler Layer

  • pkg/vmcp/server/adapter/handler_factory.go:71-113 - CreateToolHandler
  • pkg/vmcp/server/adapter/handler_factory.go:115-169 - CreateResourceHandler
  • pkg/vmcp/server/adapter/handler_factory.go:171-244 - CreatePromptHandler

Request Parsing (Working Correctly)

  • pkg/mcp/parser.go:224-230 - Request _meta extraction ✅
  • pkg/mcp/parser.go:478-485 - GetMCPMeta context 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 BackendClient implementations
  • 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:

  1. Spec Alignment: Directly aligns with MCP specification types
  2. Type Safety: Compiler enforces correct metadata handling
  3. Future-Proof: Easy to add new MCP features (e.g., structured tool outputs)
  4. Clarity: Explicit response structure, no hidden context magic
  5. Testability: Easy to verify metadata preservation in tests

Implementation Plan

Phase 1: Interface Update

  1. Update BackendClient interface to return full MCP response types
  2. Update httpBackendClient implementation to preserve _meta
  3. Update mock implementations for tests

Phase 2: Conversion Layer

  1. Remove simplified conversion logic in pkg/vmcp/client/client.go
  2. Return full mcp.CallToolResult, mcp.ReadResourceResult, etc.
  3. Preserve _meta field from backend responses

Phase 3: Handler Adaptation

  1. Update handler factory to use full response types
  2. Forward _meta from backend responses to client responses
  3. Merge/combine _meta fields when aggregating multiple backends (if needed)

Phase 4: Testing

  1. Add tests for _meta preservation in responses
  2. Add integration tests with real MCP servers
  3. Test distributed tracing flow
  4. Test progress token propagation

Phase 5: Documentation

  1. Update architecture documentation
  2. Document _meta handling in vMCP
  3. 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

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).

JAORMX avatar Nov 18 '25 16:11 JAORMX