Skip to content

Commit 59468f6

Browse files
committed
refactor(MCP): Pass MCP call context to the API as W3C Baggage
Write flagsmith.client.name/version as span attributes directly, and pass all of the current span's attributes — MCP method, tool, session and client identity — as W3C Baggage on outbound Flagsmith API requests via an httpx event hook scoped to the API client only. Replaces the baggage-attaching middleware: process context is never touched, and the API receives the full call context whenever tracing is enabled. beep boop
1 parent d776a76 commit 59468f6

6 files changed

Lines changed: 70 additions & 117 deletions

File tree

mcp/src/flagsmith_mcp/server.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from flagsmith_mcp.events import EventLoggingMiddleware
1717
from flagsmith_mcp.metrics import PrometheusMiddleware
1818
from flagsmith_mcp.oauth import FlagsmithResourceAuth
19-
from flagsmith_mcp.telemetry import BaggageMiddleware, setup_telemetry
19+
from flagsmith_mcp.telemetry import propagate_span_attributes, setup_telemetry
2020

2121
ROUTE_MAPS = [
2222
RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL),
@@ -61,9 +61,11 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
6161
api_client = httpx.AsyncClient(
6262
base_url=settings.flagsmith_api_url,
6363
auth=FlagsmithAuth(settings.flagsmith_api_token),
64+
event_hooks={"request": [propagate_span_attributes]},
6465
)
6566
# Instrument only the Flagsmith API client: emit a span per upstream
66-
# call and propagate W3C trace context and baggage to the API.
67+
# call and propagate W3C trace context; the event hook passes the MCP
68+
# call context to the API as W3C Baggage.
6769
HTTPXClientInstrumentor().instrument_client(api_client)
6870
server = FastMCP.from_openapi(
6971
openapi_spec=_fetch_spec(),
@@ -77,7 +79,6 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
7779

7880
server.add_middleware(PrometheusMiddleware())
7981
server.add_middleware(EventLoggingMiddleware())
80-
server.add_middleware(BaggageMiddleware())
8182

8283
@server.custom_route("/health", methods=["GET"])
8384
async def health(request: Request) -> PlainTextResponse:

mcp/src/flagsmith_mcp/telemetry.py

Lines changed: 18 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
from typing import Any
2-
3-
import mcp.types as mt
1+
import httpx
42
from common.core.logging import setup_logging
53
from common.core.otel import (
64
add_otel_trace_context,
75
build_otel_log_provider,
86
build_tracer_provider,
97
make_structlog_otel_processor,
108
)
11-
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
12-
from fastmcp.tools.base import ToolResult
139
from opentelemetry import baggage, trace
14-
from opentelemetry import context as otel_context
10+
from opentelemetry.baggage.propagation import W3CBaggagePropagator
1511
from opentelemetry.context import Context
16-
from opentelemetry.sdk.trace import Span, SpanProcessor
12+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
1713
from structlog.typing import Processor
1814

1915
from flagsmith_mcp import config
@@ -22,59 +18,24 @@
2218
APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"]
2319

2420

25-
def is_flagsmith_baggage_key(key: str) -> bool:
26-
return key.startswith("flagsmith.")
27-
28-
29-
class FlagsmithBaggageSpanProcessor(SpanProcessor):
30-
"""Copy flagsmith.* baggage entries onto started spans.
31-
32-
The off-the-shelf opentelemetry-processor-baggage reads baggage from
33-
the span's parent context, but FastMCP starts its server spans from a
34-
context extracted from the request _meta, which carries no baggage.
35-
Read the current context, where middleware attached the entries.
36-
"""
21+
class ClientInfoSpanProcessor(SpanProcessor):
22+
"""Annotate started spans with the MCP client identity."""
3723

3824
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
39-
for key, value in baggage.get_all().items():
40-
if is_flagsmith_baggage_key(key):
41-
span.set_attribute(key, str(value))
25+
if (client_info := get_client_info()) is not None:
26+
span.set_attribute("flagsmith.client.name", client_info.name)
27+
span.set_attribute("flagsmith.client.version", client_info.version)
4228

4329

44-
class BaggageMiddleware(Middleware):
45-
"""Attach the client identity and tool name as W3C Baggage: the single
46-
source both for span attributes (via BaggageSpanProcessor) and for
47-
propagation to the Flagsmith API by the instrumented upstream client."""
48-
49-
async def on_request(
50-
self,
51-
context: MiddlewareContext[mt.Request[Any, Any]],
52-
call_next: CallNext[mt.Request[Any, Any], Any],
53-
) -> Any:
54-
if (client_info := get_client_info()) is None:
55-
return await call_next(context)
56-
ctx = baggage.set_baggage("flagsmith.client.name", client_info.name)
57-
ctx = baggage.set_baggage(
58-
"flagsmith.client.version", client_info.version, context=ctx
59-
)
60-
token = otel_context.attach(ctx)
61-
try:
62-
return await call_next(context)
63-
finally:
64-
otel_context.detach(token)
65-
66-
async def on_call_tool(
67-
self,
68-
context: MiddlewareContext[mt.CallToolRequestParams],
69-
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
70-
) -> ToolResult:
71-
token = otel_context.attach(
72-
baggage.set_baggage("flagsmith.tool.name", context.message.name)
73-
)
74-
try:
75-
return await call_next(context)
76-
finally:
77-
otel_context.detach(token)
30+
async def propagate_span_attributes(request: httpx.Request) -> None:
31+
span = trace.get_current_span()
32+
if not isinstance(span, ReadableSpan):
33+
return
34+
ctx: Context | None = None
35+
for key, value in (span.attributes or {}).items():
36+
ctx = baggage.set_baggage(key, str(value), context=ctx)
37+
if ctx is not None:
38+
W3CBaggagePropagator().inject(request.headers, context=ctx)
7839

7940

8041
def setup_telemetry(settings: config.Settings) -> None:
@@ -97,7 +58,7 @@ def setup_telemetry(settings: config.Settings) -> None:
9758
endpoint=f"{endpoint}/v1/traces",
9859
service_name=settings.otel_service_name,
9960
)
100-
tracer_provider.add_span_processor(FlagsmithBaggageSpanProcessor())
61+
tracer_provider.add_span_processor(ClientInfoSpanProcessor())
10162
trace.set_tracer_provider(tracer_provider)
10263
setup_logging(
10364
log_level=settings.log_level,

mcp/tests/integration/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from flagsmith_mcp import config, constants
1818
from flagsmith_mcp import server as server_module
19-
from flagsmith_mcp.telemetry import FlagsmithBaggageSpanProcessor
19+
from flagsmith_mcp.telemetry import ClientInfoSpanProcessor
2020

2121
HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]]
2222

@@ -28,7 +28,7 @@ def span_exporter() -> InMemorySpanExporter:
2828
exporter = InMemorySpanExporter()
2929
provider = TracerProvider()
3030
provider.add_span_processor(SimpleSpanProcessor(exporter))
31-
provider.add_span_processor(FlagsmithBaggageSpanProcessor())
31+
provider.add_span_processor(ClientInfoSpanProcessor())
3232
trace.set_tracer_provider(provider)
3333
return exporter
3434

mcp/tests/integration/test_spans.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from urllib.parse import unquote
2+
13
from fastmcp import Client
24
from fastmcp.client.transports import FastMCPTransport
35
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
@@ -29,15 +31,15 @@ async def test_spans__tool_call__annotated_with_client_identity(
2931
assert span.attributes is not None
3032
assert {
3133
"gen_ai.tool.name": "list_environments",
32-
"flagsmith.tool.name": "list_environments",
3334
"flagsmith.client.name": "mcp",
3435
"flagsmith.client.version": "0.1.0",
3536
}.items() <= dict(span.attributes).items()
3637

3738

38-
async def test_spans__tool_call__upstream_request_carries_baggage(
39+
async def test_spans__tool_call__upstream_request_carries_span_attribute_baggage(
3940
client: Client[FastMCPTransport],
4041
respx_mock: MockRouter,
42+
finished_spans: InMemorySpanExporter,
4143
) -> None:
4244
# Given
4345
route = respx_mock.get("https://api.flagsmith.com/environments/")
@@ -46,9 +48,17 @@ async def test_spans__tool_call__upstream_request_carries_baggage(
4648
# When
4749
await client.call_tool("list_environments", {})
4850

49-
# Then the instrumented API client propagated W3C Baggage
50-
assert route.calls.last.request.headers["baggage"] == (
51-
"flagsmith.client.name=mcp,"
52-
"flagsmith.client.version=0.1.0,"
53-
"flagsmith.tool.name=list_environments"
54-
)
51+
# Then the server span's attributes reached the API as W3C Baggage
52+
entries = {
53+
key: unquote(value)
54+
for key, value in (
55+
entry.split("=", 1)
56+
for entry in route.calls.last.request.headers["baggage"].split(",")
57+
)
58+
}
59+
assert {
60+
"mcp.method.name": "tools/call",
61+
"gen_ai.tool.name": "list_environments",
62+
"flagsmith.client.name": "mcp",
63+
"flagsmith.client.version": "0.1.0",
64+
}.items() <= entries.items()

mcp/tests/unit/test_event_logging.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,22 @@ def test_get_client_info__outside_request_context__returns_none() -> None:
1010
assert events.get_client_info() is None
1111

1212

13-
def test_get_client_info__uninitialised_session__returns_none(
13+
def test_get_client_info__no_client_params__returns_none(
1414
mocker: MockerFixture,
1515
) -> None:
16-
# Given a session that has not completed initialize
16+
# Given a session that has not yet completed initialize
1717
context_mock = mocker.patch.object(events, "get_context", autospec=True)
1818
context_mock.return_value.session.client_params = None
1919

2020
# When / Then
2121
assert events.get_client_info() is None
2222

2323

24-
async def test_event_logging_middleware__uninitialised_session__empty_client_identity(
24+
async def test_event_logging_middleware__no_client_info__empty_client_identity(
2525
mocker: MockerFixture,
2626
log: StructuredLogCapture,
2727
) -> None:
28-
# Given a tool call outside an initialised session
28+
# Given
2929
middleware = events.EventLoggingMiddleware()
3030
context = mocker.Mock()
3131
context.message.name = "list_environments"

mcp/tests/unit/test_telemetry.py

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import os
22

3+
import httpx
34
from common.core.otel import add_otel_trace_context
4-
from opentelemetry import baggage
5-
from opentelemetry import context as otel_context
5+
from opentelemetry import trace
6+
from opentelemetry.sdk.trace import ReadableSpan
67
from pytest_mock import MockerFixture
78

89
from flagsmith_mcp import config, telemetry
@@ -77,7 +78,7 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces(
7778
[span_processor] = (
7879
build_tracer_provider_mock.return_value.add_span_processor.call_args.args
7980
)
80-
assert isinstance(span_processor, telemetry.FlagsmithBaggageSpanProcessor)
81+
assert isinstance(span_processor, telemetry.ClientInfoSpanProcessor)
8182
setup_logging_mock.assert_called_once_with(
8283
log_level="DEBUG",
8384
log_format="json",
@@ -89,63 +90,43 @@ def test_setup_telemetry__otlp_endpoint__exports_logs_and_traces(
8990
)
9091

9192

92-
async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only(
93+
def test_client_info_span_processor__outside_request_context__no_attributes(
9394
mocker: MockerFixture,
9495
) -> None:
95-
# Given a tool call outside an initialised session
96-
middleware = telemetry.BaggageMiddleware()
97-
context = mocker.Mock()
98-
context.message.name = "list_environments"
99-
seen_baggage: dict[str, object] = {}
100-
101-
async def record_baggage(ctx: object) -> None:
102-
seen_baggage.update(baggage.get_all())
103-
104-
call_next = mocker.AsyncMock(side_effect=record_baggage)
96+
# Given no MCP request context
97+
span = mocker.Mock()
10598

10699
# When
107-
await middleware.on_call_tool(context, call_next)
100+
telemetry.ClientInfoSpanProcessor().on_start(span)
108101

109102
# Then
110-
assert seen_baggage == {"flagsmith.tool.name": "list_environments"}
111-
assert baggage.get_all() == {}
103+
span.set_attribute.assert_not_called()
112104

113105

114-
async def test_baggage_middleware__uninitialised_session__request_baggage_untouched(
115-
mocker: MockerFixture,
116-
) -> None:
117-
# Given a request outside an initialised session
118-
middleware = telemetry.BaggageMiddleware()
119-
seen_baggage: dict[str, object] = {}
120-
121-
async def record_baggage(ctx: object) -> None:
122-
seen_baggage.update(baggage.get_all())
123-
124-
call_next = mocker.AsyncMock(side_effect=record_baggage)
106+
async def test_propagate_span_attributes__no_recording_span__headers_untouched() -> (
107+
None
108+
):
109+
# Given no SDK span in the current context
110+
request = httpx.Request("GET", "https://api.flagsmith.com/")
125111

126112
# When
127-
await middleware.on_request(mocker.Mock(), call_next)
113+
await telemetry.propagate_span_attributes(request)
128114

129115
# Then
130-
assert seen_baggage == {}
116+
assert "baggage" not in request.headers
131117

132118

133-
def test_flagsmith_baggage_span_processor__foreign_baggage__not_copied(
119+
async def test_propagate_span_attributes__span_without_attributes__headers_untouched(
134120
mocker: MockerFixture,
135121
) -> None:
136-
# Given baggage with flagsmith and foreign entries
137-
span = mocker.Mock()
138-
ctx = baggage.set_baggage("other.key", "x")
139-
ctx = baggage.set_baggage("flagsmith.tool.name", "list_environments", context=ctx)
140-
token = otel_context.attach(ctx)
122+
# Given a recording span with no attributes
123+
span = mocker.Mock(spec=ReadableSpan)
124+
span.attributes = {}
125+
mocker.patch.object(trace, "get_current_span", return_value=span)
141126

142127
# When
143-
try:
144-
telemetry.FlagsmithBaggageSpanProcessor().on_start(span)
145-
finally:
146-
otel_context.detach(token)
128+
request = httpx.Request("GET", "https://api.flagsmith.com/")
129+
await telemetry.propagate_span_attributes(request)
147130

148131
# Then
149-
span.set_attribute.assert_called_once_with(
150-
"flagsmith.tool.name", "list_environments"
151-
)
132+
assert "baggage" not in request.headers

0 commit comments

Comments
 (0)