Skip to content

Commit 36ef8dd

Browse files
authored
feat(MCP): Emit session and tool call events (#7707)
1 parent f3c738b commit 36ef8dd

12 files changed

Lines changed: 529 additions & 20 deletions

File tree

.pre-commit-config.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ repos:
1414
- id: ruff
1515
name: mcp-lint
1616
files: ^mcp/
17-
args: [--config, mcp/pyproject.toml, --config, "src = ['mcp/src']", --fix]
17+
args:
18+
[--config, mcp/pyproject.toml, --config, "src = ['mcp/src']", --fix]
1819
- id: ruff-format
1920
name: mcp-format
2021
files: ^mcp/
@@ -43,6 +44,7 @@ repos:
4344
language: system
4445
entry: make -C api generate-docs
4546
pass_filenames: false
47+
files: ^api/
4648
types_or: [python, toml]
4749
- id: api-typecheck
4850
name: api-typecheck
@@ -69,7 +71,7 @@ repos:
6971
- id: flagsmith-lint-tests
7072

7173
- repo: https://github.com/astral-sh/uv-pre-commit
72-
rev: 0.11.18 # Ensure this matches the version in api/pyproject.toml
74+
rev: 0.11.18 # Ensure this matches the version in api/pyproject.toml
7375
hooks:
7476
- id: uv-lock
7577
name: api-lockcheck

mcp/pyproject.toml

Lines changed: 2 additions & 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
]
@@ -24,6 +25,7 @@ dev = [
2425
"pytest-asyncio>=1.3.0,<2.0.0", # Run asynchronous tests
2526
"pytest-cov>=7.0.0,<8.0.0", # Measure test coverage
2627
"pytest-mock>=3.15.1,<4.0.0", # Mock via fixtures
28+
"pytest-structlog>=1.1,<2.0.0", # Assert structlog events
2729
"respx>=0.22,<1.0", # Mock HTTP interactions
2830
"ruff>=0.15.12,<0.16.0", # Lint and format
2931
]

mcp/src/flagsmith_mcp/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
# TODO: consume a version-controlled schema — https://github.com/Flagsmith/flagsmith/issues/7669
22
OPENAPI_SPEC_URL = "https://api.flagsmith.com/api/v1/swagger.json"
33
OAUTH_SCOPES = ["mcp"]
4+
5+
# How this service identifies itself as a client of the Flagsmith API.
6+
FLAGSMITH_CLIENT_NAME = "flagsmith-mcp"

mcp/src/flagsmith_mcp/events.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import mcp.types as mt
2+
import structlog
3+
from fastmcp.server.dependencies import get_context
4+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
5+
from fastmcp.tools.base import ToolResult
6+
7+
logger = structlog.get_logger("mcp")
8+
9+
10+
def get_client_info() -> mt.Implementation | None:
11+
"""The connected client's self-declared identity, captured by the
12+
session during initialize."""
13+
try:
14+
client_params = get_context().session.client_params
15+
except RuntimeError:
16+
return None
17+
if client_params is None:
18+
return None
19+
return client_params.clientInfo
20+
21+
22+
class EventLoggingMiddleware(Middleware):
23+
"""Emit structured product events for MCP sessions and tool calls."""
24+
25+
async def on_initialize(
26+
self,
27+
context: MiddlewareContext[mt.InitializeRequest],
28+
call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None],
29+
) -> mt.InitializeResult | None:
30+
result = await call_next(context)
31+
client_info = context.message.params.clientInfo
32+
logger.info(
33+
"session.opened",
34+
flagsmith__mcp__client__name=client_info.name,
35+
flagsmith__mcp__client__version=client_info.version,
36+
)
37+
return result
38+
39+
async def on_call_tool(
40+
self,
41+
context: MiddlewareContext[mt.CallToolRequestParams],
42+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
43+
) -> ToolResult:
44+
client_info = get_client_info()
45+
client_name = client_info.name if client_info else ""
46+
client_version = client_info.version if client_info else ""
47+
try:
48+
result = await call_next(context)
49+
except Exception:
50+
logger.info(
51+
"tool.called",
52+
tool__name=context.message.name,
53+
flagsmith__mcp__client__name=client_name,
54+
flagsmith__mcp__client__version=client_version,
55+
status="error",
56+
)
57+
raise
58+
logger.info(
59+
"tool.called",
60+
tool__name=context.message.name,
61+
flagsmith__mcp__client__name=client_name,
62+
flagsmith__mcp__client__version=client_version,
63+
status="success",
64+
)
65+
return result

mcp/src/flagsmith_mcp/server.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
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
1213

1314
from flagsmith_mcp import config, constants
1415
from flagsmith_mcp.auth import FlagsmithAuth
16+
from flagsmith_mcp.events import EventLoggingMiddleware
1517
from flagsmith_mcp.metrics import PrometheusMiddleware
1618
from flagsmith_mcp.oauth import FlagsmithResourceAuth
17-
from flagsmith_mcp.telemetry import setup_telemetry
19+
from flagsmith_mcp.telemetry import propagate_span_attributes, setup_telemetry
1820

1921
ROUTE_MAPS = [
2022
RouteMap(tags={"mcp"}, mcp_type=MCPType.TOOL),
@@ -56,12 +58,18 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
5658
resource_url=settings.mcp_server_url,
5759
authorization_server=settings.flagsmith_api_url,
5860
)
61+
api_client = httpx.AsyncClient(
62+
base_url=settings.flagsmith_api_url,
63+
auth=FlagsmithAuth(settings.flagsmith_api_token),
64+
event_hooks={"request": [propagate_span_attributes]},
65+
)
66+
# Instrument only the Flagsmith API client: emit a span per upstream
67+
# call and propagate W3C trace context; the event hook passes the MCP
68+
# call context to the API as W3C Baggage.
69+
HTTPXClientInstrumentor().instrument_client(api_client)
5970
server = FastMCP.from_openapi(
6071
openapi_spec=_fetch_spec(),
61-
client=httpx.AsyncClient(
62-
base_url=settings.flagsmith_api_url,
63-
auth=FlagsmithAuth(settings.flagsmith_api_token),
64-
),
72+
client=api_client,
6573
name="Flagsmith",
6674
route_maps=ROUTE_MAPS,
6775
mcp_component_fn=_customise,
@@ -70,6 +78,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
7078
)
7179

7280
server.add_middleware(PrometheusMiddleware())
81+
server.add_middleware(EventLoggingMiddleware())
7382

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

mcp/src/flagsmith_mcp/telemetry.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,45 @@
1+
import httpx
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 opentelemetry import baggage, trace
10+
from opentelemetry.baggage.propagation import W3CBaggagePropagator
11+
from opentelemetry.context import Context
12+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor, TracerProvider
913
from structlog.typing import Processor
1014

11-
from flagsmith_mcp import config
15+
from flagsmith_mcp import config, constants
16+
from flagsmith_mcp.events import get_client_info
1217

1318
APPLICATION_LOGGERS = ["flagsmith_mcp", "fastmcp", "mcp"]
1419

1520

21+
class ClientInfoSpanProcessor(SpanProcessor):
22+
"""Annotate started spans with this service's identity and the MCP
23+
client identity."""
24+
25+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
26+
span.set_attribute("flagsmith.client.name", constants.FLAGSMITH_CLIENT_NAME)
27+
if (client_info := get_client_info()) is not None:
28+
span.set_attribute("flagsmith.mcp.client.name", client_info.name)
29+
span.set_attribute("flagsmith.mcp.client.version", client_info.version)
30+
31+
32+
async def propagate_span_attributes(request: httpx.Request) -> None:
33+
span = trace.get_current_span()
34+
if not isinstance(span, ReadableSpan):
35+
return
36+
ctx: Context | None = None
37+
for key, value in (span.attributes or {}).items():
38+
ctx = baggage.set_baggage(key, str(value), context=ctx)
39+
if ctx is not None:
40+
W3CBaggagePropagator().inject(request.headers, context=ctx)
41+
42+
1643
def setup_telemetry(settings: config.Settings) -> None:
1744
"""Set up logging, exporting structlog events and traces to OpenTelemetry
1845
when an OTLP endpoint is configured."""
@@ -27,14 +54,16 @@ def setup_telemetry(settings: config.Settings) -> None:
2754
add_otel_trace_context,
2855
make_structlog_otel_processor(log_provider),
2956
]
30-
# Setting a global tracer provider also activates FastMCP's built-in
31-
# per-request server spans.
32-
trace.set_tracer_provider(
33-
build_tracer_provider(
34-
endpoint=f"{endpoint}/v1/traces",
35-
service_name=settings.otel_service_name,
36-
)
57+
tracer_provider = build_tracer_provider(
58+
endpoint=f"{endpoint}/v1/traces",
59+
service_name=settings.otel_service_name,
3760
)
61+
else:
62+
# No exporter: spans stay in-process, but still feed the API
63+
# baggage propagation.
64+
tracer_provider = TracerProvider()
65+
tracer_provider.add_span_processor(ClientInfoSpanProcessor())
66+
trace.set_tracer_provider(tracer_provider)
3867
setup_logging(
3968
log_level=settings.log_level,
4069
log_format=settings.log_format,

mcp/tests/integration/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,39 @@
66
import pytest
77
from fastmcp import Client, FastMCP
88
from fastmcp.client.transports import FastMCPTransport
9+
from opentelemetry import trace
10+
from opentelemetry.sdk.trace import TracerProvider
11+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
12+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
13+
InMemorySpanExporter,
14+
)
915
from respx import MockRouter
1016

1117
from flagsmith_mcp import config, constants
1218
from flagsmith_mcp import server as server_module
19+
from flagsmith_mcp.telemetry import ClientInfoSpanProcessor
1320

1421
HTTPClientFactoryFixture = Callable[[FastMCP], AsyncIterator[httpx.AsyncClient]]
1522

1623

24+
@pytest.fixture(scope="session")
25+
def span_exporter() -> InMemorySpanExporter:
26+
# The global tracer provider can only be set once per process, hence
27+
# the session scope.
28+
exporter = InMemorySpanExporter()
29+
provider = TracerProvider()
30+
provider.add_span_processor(SimpleSpanProcessor(exporter))
31+
provider.add_span_processor(ClientInfoSpanProcessor())
32+
trace.set_tracer_provider(provider)
33+
return exporter
34+
35+
36+
@pytest.fixture
37+
def finished_spans(span_exporter: InMemorySpanExporter) -> InMemorySpanExporter:
38+
span_exporter.clear()
39+
return span_exporter
40+
41+
1742
@pytest.fixture
1843
def openapi_spec() -> openapi.OpenAPI:
1944
ok = openapi.Response(description="OK")
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pytest
2+
from fastmcp import Client
3+
from fastmcp.client.transports import FastMCPTransport
4+
from fastmcp.exceptions import ToolError
5+
from pytest_structlog import StructuredLogCapture
6+
from respx import MockRouter
7+
8+
9+
async def test_events__session_initialised__emits_session_opened(
10+
log: StructuredLogCapture,
11+
client: Client[FastMCPTransport],
12+
) -> None:
13+
# Given the server started via the client fixture
14+
# When the session is initialised by the fixture
15+
# Then the client's self-declared identity is reported
16+
assert log.has(
17+
"session.opened",
18+
flagsmith__mcp__client__name="mcp",
19+
flagsmith__mcp__client__version="0.1.0",
20+
)
21+
22+
23+
async def test_events__successful_tool_call__emits_tool_called(
24+
log: StructuredLogCapture,
25+
client: Client[FastMCPTransport],
26+
respx_mock: MockRouter,
27+
) -> None:
28+
# Given
29+
respx_mock.get("https://api.flagsmith.com/environments/").respond(
30+
json={"results": []}
31+
)
32+
33+
# When
34+
await client.call_tool("list_environments", {})
35+
36+
# Then
37+
[event] = [e for e in log.events if e["event"] == "tool.called"]
38+
assert event == {
39+
"event": "tool.called",
40+
"level": "info",
41+
"tool__name": "list_environments",
42+
"flagsmith__mcp__client__name": "mcp",
43+
"flagsmith__mcp__client__version": "0.1.0",
44+
"status": "success",
45+
}
46+
47+
48+
async def test_events__failing_tool_call__emits_tool_called_with_error_status(
49+
log: StructuredLogCapture,
50+
client: Client[FastMCPTransport],
51+
respx_mock: MockRouter,
52+
) -> None:
53+
# Given
54+
respx_mock.get("https://api.flagsmith.com/environments/").respond(status_code=502)
55+
56+
# When
57+
with pytest.raises(ToolError):
58+
await client.call_tool("list_environments", {})
59+
60+
# Then
61+
[event] = [e for e in log.events if e["event"] == "tool.called"]
62+
assert event == {
63+
"event": "tool.called",
64+
"level": "info",
65+
"tool__name": "list_environments",
66+
"flagsmith__mcp__client__name": "mcp",
67+
"flagsmith__mcp__client__version": "0.1.0",
68+
"status": "error",
69+
}

0 commit comments

Comments
 (0)