Skip to content

Commit 552f06d

Browse files
committed
feat(MCP): Emit session and tool call events
Contributes to Flagsmith/flagsmith-private#152. A middleware emits structured events on the mcp domain: - session.opened on initialize, with the client name mapped from the User-Agent product token against MCP_KNOWN_CLIENTS - client.unknown when the User-Agent is not recognised, so real-world client User-Agents can be discovered and added to the known set - tool.called for every tools/call, with tool, client, status and duration - tool.failed on the error path, with exc_info beep boop
1 parent b43159c commit 552f06d

7 files changed

Lines changed: 230 additions & 0 deletions

File tree

mcp/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dev = [
2424
"pytest-asyncio>=1.3.0,<2.0.0", # Run asynchronous tests
2525
"pytest-cov>=7.0.0,<8.0.0", # Measure test coverage
2626
"pytest-mock>=3.15.1,<4.0.0", # Mock via fixtures
27+
"pytest-structlog>=1.1,<2.0.0", # Assert structlog events
2728
"respx>=0.22,<1.0", # Mock HTTP interactions
2829
"ruff>=0.15.12,<0.16.0", # Lint and format
2930
]

mcp/src/flagsmith_mcp/constants.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
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+
# Mirrors MCP_KNOWN_CLIENTS in the Core API's app_analytics.
6+
UNKNOWN_CLIENT = "unknown"
7+
MCP_KNOWN_CLIENTS = {
8+
"claude-code",
9+
"claude-desktop",
10+
"cursor",
11+
"windsurf",
12+
"zed",
13+
"mcp-remote",
14+
}

mcp/src/flagsmith_mcp/events.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import time
2+
3+
import mcp.types as mt
4+
import structlog
5+
from fastmcp.server.dependencies import get_http_headers
6+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
7+
from fastmcp.tools.base import ToolResult
8+
9+
from flagsmith_mcp import constants
10+
11+
logger = structlog.get_logger("mcp")
12+
13+
14+
def get_user_agent() -> str:
15+
return get_http_headers(include={"user-agent"}).get("user-agent", "")
16+
17+
18+
def get_client_name(user_agent: str) -> str:
19+
"""Map a User-Agent to a known MCP client name.
20+
21+
The product token (the part before the first slash) identifies
22+
the client, e.g. `claude-code/2.1 (cli)` -> `claude-code`.
23+
"""
24+
name = user_agent.partition("/")[0].strip().lower()
25+
if name in constants.MCP_KNOWN_CLIENTS:
26+
return name
27+
return constants.UNKNOWN_CLIENT
28+
29+
30+
class EventLoggingMiddleware(Middleware):
31+
"""Emit structured product events for MCP sessions and tool calls."""
32+
33+
async def on_initialize(
34+
self,
35+
context: MiddlewareContext[mt.InitializeRequest],
36+
call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None],
37+
) -> mt.InitializeResult | None:
38+
result = await call_next(context)
39+
user_agent = get_user_agent()
40+
client_name = get_client_name(user_agent)
41+
if client_name == constants.UNKNOWN_CLIENT:
42+
logger.info("client.unknown", client__user_agent=user_agent)
43+
logger.info(
44+
"session.opened",
45+
client__name=client_name,
46+
client__user_agent=user_agent,
47+
)
48+
return result
49+
50+
async def on_call_tool(
51+
self,
52+
context: MiddlewareContext[mt.CallToolRequestParams],
53+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
54+
) -> ToolResult:
55+
client_name = get_client_name(get_user_agent())
56+
start = time.perf_counter()
57+
try:
58+
result = await call_next(context)
59+
except Exception:
60+
duration_ms = (time.perf_counter() - start) * 1000
61+
logger.info(
62+
"tool.called",
63+
tool__name=context.message.name,
64+
client__name=client_name,
65+
status="error",
66+
duration_ms=duration_ms,
67+
)
68+
logger.exception(
69+
"tool.failed",
70+
tool__name=context.message.name,
71+
client__name=client_name,
72+
status="error",
73+
duration_ms=duration_ms,
74+
)
75+
raise
76+
logger.info(
77+
"tool.called",
78+
tool__name=context.message.name,
79+
client__name=client_name,
80+
status="success",
81+
duration_ms=(time.perf_counter() - start) * 1000,
82+
)
83+
return result

mcp/src/flagsmith_mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from flagsmith_mcp import config, constants
1414
from flagsmith_mcp.auth import FlagsmithAuth
15+
from flagsmith_mcp.events import EventLoggingMiddleware
1516
from flagsmith_mcp.metrics import PrometheusMiddleware
1617
from flagsmith_mcp.oauth import FlagsmithResourceAuth
1718
from flagsmith_mcp.telemetry import setup_telemetry
@@ -70,6 +71,7 @@ def create_server(settings: config.Settings) -> FastMCP[None]:
7071
)
7172

7273
server.add_middleware(PrometheusMiddleware())
74+
server.add_middleware(EventLoggingMiddleware())
7375

7476
@server.custom_route("/health", methods=["GET"])
7577
async def health(request: Request) -> PlainTextResponse:
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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, with no HTTP context
14+
# When the session is initialised by the fixture
15+
# Then the client is reported as unknown
16+
assert log.has("session.opened", client__name="unknown", client__user_agent="")
17+
assert log.has("client.unknown", client__user_agent="")
18+
19+
20+
async def test_events__successful_tool_call__emits_tool_called(
21+
log: StructuredLogCapture,
22+
client: Client[FastMCPTransport],
23+
respx_mock: MockRouter,
24+
) -> None:
25+
# Given
26+
respx_mock.get("https://api.flagsmith.com/environments/").respond(
27+
json={"results": []}
28+
)
29+
30+
# When
31+
await client.call_tool("list_environments", {})
32+
33+
# Then
34+
[event] = [e for e in log.events if e["event"] == "tool.called"]
35+
assert event.pop("duration_ms") >= 0
36+
assert event == {
37+
"event": "tool.called",
38+
"level": "info",
39+
"tool__name": "list_environments",
40+
"client__name": "unknown",
41+
"status": "success",
42+
}
43+
assert not log.has("tool.failed")
44+
45+
46+
async def test_events__failing_tool_call__emits_tool_called_and_tool_failed(
47+
log: StructuredLogCapture,
48+
client: Client[FastMCPTransport],
49+
respx_mock: MockRouter,
50+
) -> None:
51+
# Given
52+
respx_mock.get("https://api.flagsmith.com/environments/").respond(status_code=502)
53+
54+
# When
55+
with pytest.raises(ToolError):
56+
await client.call_tool("list_environments", {})
57+
58+
# Then
59+
[called] = [e for e in log.events if e["event"] == "tool.called"]
60+
assert called["status"] == "error"
61+
[failed] = [e for e in log.events if e["event"] == "tool.failed"]
62+
assert failed.pop("duration_ms") >= 0
63+
assert failed.pop("exc_info") is True
64+
assert failed == {
65+
"event": "tool.failed",
66+
"level": "error",
67+
"tool__name": "list_environments",
68+
"client__name": "unknown",
69+
"status": "error",
70+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
from pytest_mock import MockerFixture
3+
from pytest_structlog import StructuredLogCapture
4+
5+
from flagsmith_mcp import events
6+
7+
8+
@pytest.mark.parametrize(
9+
"user_agent, expected",
10+
[
11+
("claude-code/2.1 (cli)", "claude-code"),
12+
("Claude-Desktop/1.0", "claude-desktop"),
13+
("Cursor/1.2.3", "cursor"),
14+
("zed", "zed"),
15+
("mcp-remote/0.1.29 node/22.0.0", "mcp-remote"),
16+
("python-httpx/0.28", "unknown"),
17+
("", "unknown"),
18+
],
19+
)
20+
def test_get_client_name__user_agent__maps_to_known_client_or_unknown(
21+
user_agent: str,
22+
expected: str,
23+
) -> None:
24+
# Given the parametrised User-Agent
25+
# When / Then
26+
assert events.get_client_name(user_agent) == expected
27+
28+
29+
async def test_event_logging_middleware__known_client__no_unknown_client_event(
30+
mocker: MockerFixture,
31+
log: StructuredLogCapture,
32+
) -> None:
33+
# Given
34+
user_agent = "claude-code/2.1 (cli)"
35+
mocker.patch.object(events, "get_user_agent", return_value=user_agent)
36+
middleware = events.EventLoggingMiddleware()
37+
call_next = mocker.AsyncMock(return_value=None)
38+
39+
# When
40+
await middleware.on_initialize(mocker.Mock(), call_next)
41+
42+
# Then
43+
assert log.has(
44+
"session.opened",
45+
client__name="claude-code",
46+
client__user_agent=user_agent,
47+
)
48+
assert not log.has("client.unknown")

mcp/uv.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)