Skip to content

Commit eee6c1e

Browse files
committed
feat(MCP): Propagate client identity and tool name to the API
Instrument the Flagsmith API httpx client (instance-only, via opentelemetry-instrumentation-httpx) so every upstream call emits a client span and carries W3C trace context and baggage. A middleware sets flagsmith.tool.name and flagsmith.client.name/version as baggage per tool call; the API's existing OTel pipeline extracts baggage and copies it onto its own events, giving per-client, per-tool visibility on both sides of the boundary. beep boop
1 parent 10e7004 commit eee6c1e

6 files changed

Lines changed: 200 additions & 6 deletions

File tree

‎mcp/pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ requires-python = ">=3.11"
88
dependencies = [
99
"fastmcp>=3.3.1,<4.0.0", # Base MCP functionality
1010
"flagsmith-common[otel]>=3.10.0,<4.0.0", # Logging and OTel export
11+
"opentelemetry-instrumentation-httpx>=0.46b0,<1.0.0", # Trace upstream API calls
1112
"prometheus-client>=0.21.0,<1.0.0", # Export Prometheus metrics
1213
"pydantic-settings>=2.0.0,<3.0.0", # Environment-driven configuration
1314
]

‎mcp/src/flagsmith_mcp/server.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from fastmcp.utilities.components import FastMCPComponent
77
from fastmcp.utilities.openapi.models import HttpMethod, HTTPRoute
88
from mcp.types import ToolAnnotations
9+
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
910
from prometheus_client import start_http_server
1011
from starlette.requests import Request
1112
from starlette.responses import PlainTextResponse
@@ -15,7 +16,7 @@
1516
from flagsmith_mcp.events import EventLoggingMiddleware
1617
from flagsmith_mcp.metrics import PrometheusMiddleware
1718
from flagsmith_mcp.oauth import FlagsmithResourceAuth
18-
from flagsmith_mcp.telemetry import setup_telemetry
19+
from flagsmith_mcp.telemetry import BaggageMiddleware, setup_telemetry
1920

2021
ROUTE_MAPS = [
2122
RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL),
@@ -57,12 +58,16 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
5758
resource_url=settings.mcp_server_url,
5859
authorization_server=settings.flagsmith_api_url,
5960
)
61+
api_client = httpx.AsyncClient(
62+
base_url=settings.flagsmith_api_url,
63+
auth=FlagsmithAuth(settings.flagsmith_api_token),
64+
)
65+
# Instrument only the Flagsmith API client: emit a span per upstream
66+
# call and propagate W3C trace context and baggage to the API.
67+
HTTPXClientInstrumentor().instrument_client(api_client)
6068
server = FastMCP.from_openapi(
6169
openapi_spec=_fetch_spec(),
62-
client=httpx.AsyncClient(
63-
base_url=settings.flagsmith_api_url,
64-
auth=FlagsmithAuth(settings.flagsmith_api_token),
65-
),
70+
client=api_client,
6671
name="Flagsmith",
6772
route_maps=ROUTE_MAPS,
6873
mcp_component_fn=_customise,
@@ -72,6 +77,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
7277

7378
server.add_middleware(PrometheusMiddleware())
7479
server.add_middleware(EventLoggingMiddleware())
80+
server.add_middleware(BaggageMiddleware())
7581

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

‎mcp/src/flagsmith_mcp/telemetry.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
1+
import mcp.types as mt
12
from common.core.logging import setup_logging
23
from common.core.otel import (
34
add_otel_trace_context,
45
build_otel_log_provider,
56
build_tracer_provider,
67
make_structlog_otel_processor,
78
)
8-
from opentelemetry import trace
9+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
10+
from fastmcp.tools.base import ToolResult
11+
from opentelemetry import baggage, trace
12+
from opentelemetry import context as otel_context
913
from opentelemetry.context import Context
1014
from opentelemetry.sdk.trace import Span, SpanProcessor
1115
from structlog.typing import Processor
@@ -25,6 +29,30 @@ def on_start(self, span: Span, parent_context: Context | None = None) -> None:
2529
span.set_attribute("flagsmith.client.version", client_info.version)
2630

2731

32+
class BaggageMiddleware(Middleware):
33+
"""Attach the tool name and client identity as W3C Baggage, propagated
34+
to the Flagsmith API by the instrumented upstream HTTP client."""
35+
36+
async def on_call_tool(
37+
self,
38+
context: MiddlewareContext[mt.CallToolRequestParams],
39+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
40+
) -> ToolResult:
41+
ctx = baggage.set_baggage("flagsmith.tool.name", context.message.name)
42+
if (client_info := get_client_info()) is not None:
43+
ctx = baggage.set_baggage(
44+
"flagsmith.client.name", client_info.name, context=ctx
45+
)
46+
ctx = baggage.set_baggage(
47+
"flagsmith.client.version", client_info.version, context=ctx
48+
)
49+
token = otel_context.attach(ctx)
50+
try:
51+
return await call_next(context)
52+
finally:
53+
otel_context.detach(token)
54+
55+
2856
def setup_telemetry(settings: config.Settings) -> None:
2957
"""Set up logging, exporting structlog events and traces to OpenTelemetry
3058
when an OTLP endpoint is configured."""

‎mcp/tests/integration/test_spans.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,22 @@ async def test_spans__tool_call__annotated_with_client_identity(
3232
"flagsmith.client.name": "mcp",
3333
"flagsmith.client.version": "0.1.0",
3434
}.items() <= dict(span.attributes).items()
35+
36+
37+
async def test_spans__tool_call__upstream_request_carries_baggage(
38+
client: Client[FastMCPTransport],
39+
respx_mock: MockRouter,
40+
) -> None:
41+
# Given
42+
route = respx_mock.get("https://api.flagsmith.com/environments/")
43+
route.respond(json={"results": []})
44+
45+
# When
46+
await client.call_tool("list_environments", {})
47+
48+
# Then the instrumented API client propagated W3C Baggage
49+
assert route.calls.last.request.headers["baggage"] == (
50+
"flagsmith.tool.name=list_environments,"
51+
"flagsmith.client.name=mcp,"
52+
"flagsmith.client.version=0.1.0"
53+
)

‎mcp/tests/unit/test_telemetry.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
from common.core.otel import add_otel_trace_context
4+
from opentelemetry import baggage
45
from pytest_mock import MockerFixture
56

67
from flagsmith_mcp import config, telemetry
@@ -98,3 +99,25 @@ def test_client_info_span_processor__outside_request_context__no_attributes(
9899

99100
# Then
100101
span.set_attribute.assert_not_called()
102+
103+
104+
async def test_baggage_middleware__uninitialised_session__tool_name_baggage_only(
105+
mocker: MockerFixture,
106+
) -> None:
107+
# Given a tool call outside an initialised session
108+
middleware = telemetry.BaggageMiddleware()
109+
context = mocker.Mock()
110+
context.message.name = "list_environments"
111+
seen_baggage: dict[str, object] = {}
112+
113+
async def record_baggage(ctx: object) -> None:
114+
seen_baggage.update(baggage.get_all())
115+
116+
call_next = mocker.AsyncMock(side_effect=record_baggage)
117+
118+
# When
119+
await middleware.on_call_tool(context, call_next)
120+
121+
# Then
122+
assert seen_baggage == {"flagsmith.tool.name": "list_environments"}
123+
assert baggage.get_all() == {}

0 commit comments

Comments
 (0)