Skip to content

Virtual MCP Server Not Preserving _meta Field in Responses #2640

Description

@JAORMX

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

apiItems related to the APIbugSomething isn't workinggoPull requests that update go code

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions