Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion pkg/telemetry/propagation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package telemetry

import (
"context"
"maps"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
Expand Down Expand Up @@ -64,7 +65,8 @@ func (c *MetaCarrier) Meta() map[string]interface{} {
}

// InjectMetaTraceContext injects the current trace context from ctx directly into
// the given meta map using W3C Trace Context format (traceparent, tracestate).
// the given meta map using W3C Trace Context format (traceparent, tracestate)
// plus W3C Baggage, subject to the globally configured OTEL propagator.
//
// This function operates directly on the meta map contents. Use this when you
// already have the _meta map and want to inject trace context fields into it.
Expand All @@ -75,3 +77,30 @@ func InjectMetaTraceContext(ctx context.Context, meta map[string]interface{}) {
carrier := NewMetaCarrier(meta)
otel.GetTextMapPropagator().Inject(ctx, carrier)
}

// MetaWithTraceContext returns a COPY of meta enriched with the current W3C
// trace context (traceparent/tracestate/baggage) from ctx, per SEP-414.
// The caller's map is never mutated (copy-before-mutate). Returns nil when the
// result is empty (no caller meta and no active trace context) so callers can
// omit _meta entirely for 2025-11-25 peers that don't expect it.
//
// Overwrite semantics: injecting the CURRENT span's context overwrites any
// inbound traceparent already present in the cloned caller map. This is the
// correct behavior for an intermediary — the outbound request must be a child
// of the span active here, not of whatever the caller inherited upstream.
//
// SECURITY: the injected trace context AND baggage cross the trust boundary into
// backend requests, and backends may be untrusted. Callers must never place
// secrets or PII in baggage, since it is serialized verbatim into the outbound
// _meta. Baggage is intentionally NOT stripped: SEP-414 explicitly reserves it.
func MetaWithTraceContext(ctx context.Context, meta map[string]interface{}) map[string]interface{} {
enriched := maps.Clone(meta) // nil-safe: returns nil for nil input
if enriched == nil {
enriched = make(map[string]interface{})
}
InjectMetaTraceContext(ctx, enriched)
if len(enriched) == 0 {
return nil
}
return enriched
}
70 changes: 70 additions & 0 deletions pkg/telemetry/propagation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
Expand Down Expand Up @@ -152,3 +155,70 @@ func TestInjectMetaTraceContext_NilMeta(t *testing.T) {
// Should not panic
InjectMetaTraceContext(context.Background(), nil)
}

//nolint:paralleltest // Mutates global OTEL propagator
func TestMetaWithTraceContext(t *testing.T) {
oldPropagator := otel.GetTextMapPropagator()
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
defer otel.SetTextMapPropagator(oldPropagator)

tp := sdktrace.NewTracerProvider()
defer func() { _ = tp.Shutdown(context.Background()) }()
tracer := tp.Tracer("test")

spanCtx, span := tracer.Start(context.Background(), "test-span")
defer span.End()

t.Run("active span with nil meta returns traceparent", func(t *testing.T) {
got := MetaWithTraceContext(spanCtx, nil)
require.NotNil(t, got)
tp, ok := got["traceparent"].(string)
require.True(t, ok, "expected traceparent to be present")
assert.NotEmpty(t, tp)
})

t.Run("active span with caller meta preserves caller fields without mutating input", func(t *testing.T) {
input := map[string]any{"progressToken": "tok-123"}
got := MetaWithTraceContext(spanCtx, input)

require.NotNil(t, got)
assert.Equal(t, "tok-123", got["progressToken"])
tp, ok := got["traceparent"].(string)
require.True(t, ok, "expected traceparent to be present")
assert.NotEmpty(t, tp)

// Copy-before-mutate: the caller's original map must be untouched.
assert.Equal(t, map[string]any{"progressToken": "tok-123"}, input)
_, present := input["traceparent"]
assert.False(t, present, "input map must not be mutated with traceparent")
})

t.Run("no active span with nil meta returns nil", func(t *testing.T) {
got := MetaWithTraceContext(context.Background(), nil)
assert.Nil(t, got)
})

t.Run("no active span with caller meta returns unmutated clone", func(t *testing.T) {
input := map[string]any{"progressToken": "tok-456"}
got := MetaWithTraceContext(context.Background(), input)

require.NotNil(t, got)
assert.Equal(t, input, got)
_, present := got["traceparent"]
assert.False(t, present, "no traceparent expected without an active trace context")
})

t.Run("baggage present in ctx is propagated", func(t *testing.T) {
member, err := baggage.NewMember("user.id", "42")
require.NoError(t, err)
bag, err := baggage.New(member)
require.NoError(t, err)
ctx := baggage.ContextWithBaggage(spanCtx, bag)

got := MetaWithTraceContext(ctx, nil)
require.NotNil(t, got)
bgKey, ok := got["baggage"].(string)
require.True(t, ok, "expected baggage key to be present")
assert.Contains(t, bgKey, "user.id=42")
})
}
11 changes: 9 additions & 2 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/stacklok/toolhive-core/mcpcompat/mcp"
"github.com/stacklok/toolhive/pkg/auth"
"github.com/stacklok/toolhive/pkg/secrets"
"github.com/stacklok/toolhive/pkg/telemetry"
"github.com/stacklok/toolhive/pkg/versions"
"github.com/stacklok/toolhive/pkg/vmcp"
vmcpauth "github.com/stacklok/toolhive/pkg/vmcp/auth"
Expand Down Expand Up @@ -1056,7 +1057,7 @@ func (h *httpBackendClient) CallTool(
Params: mcp.CallToolParams{
Name: backendToolName,
Arguments: arguments,
Meta: conversion.ToMCPMeta(meta),
Meta: conversion.ToMCPMeta(telemetry.MetaWithTraceContext(ctx, meta)),
},
})
if err != nil {
Expand Down Expand Up @@ -1160,9 +1161,12 @@ func (h *httpBackendClient) ReadResource(
slog.Debug("translating resource URI", "client_uri", uri, "backend_uri", backendURI)
}

// Forward-compat: mcpcompat drops Params.Meta on this path today (no-op on
// the wire). See TestOutboundMetaTraceContext for details/tripwire.
result, err := c.ReadResource(ctx, mcp.ReadResourceRequest{
Params: mcp.ReadResourceParams{
URI: backendURI,
URI: backendURI,
Meta: conversion.ToMCPMeta(telemetry.MetaWithTraceContext(ctx, nil)),
},
})
if err != nil {
Expand Down Expand Up @@ -1215,10 +1219,13 @@ func (h *httpBackendClient) GetPrompt(

stringArgs := conversion.ConvertPromptArguments(arguments)

// Forward-compat: mcpcompat drops Params.Meta on this path today (no-op on
// the wire). See TestOutboundMetaTraceContext for details/tripwire.
result, err := c.GetPrompt(ctx, mcp.GetPromptRequest{
Params: mcp.GetPromptParams{
Name: backendPromptName,
Arguments: stringArgs,
Meta: conversion.ToMCPMeta(telemetry.MetaWithTraceContext(ctx, nil)),
},
})
if err != nil {
Expand Down
Loading
Loading