Skip to content

Commit 170f4e2

Browse files
authored
feat(mcp): emit $mcp_protocol_version on events (#795)
The MCP protocol (spec) version was carried in the session token but never stamped on events (JS emits it). Thread it through: - constants: add $mcp_protocol_version; _posthog_events emits it on the primary event and the $exception sibling. - _capture: the event normalizer now keeps protocol_version (it was silently dropped by the fixed key list -- the reason nothing surfaced before). - instrument() adapters read the live protocol version off client_params and backfill from the token on stateless pods (via resolve_session_and_client), threading it to prepare_request / record_*. - PostHogMCP capture_* methods gain a protocol_version argument. Verified on the 2-pod stateless cluster: $mcp_protocol_version lands on $mcp_initialize and every $mcp_tool_call across both pods. Generated-By: PostHog Code Task-Id: f44ec5e0-b836-4d13-98c4-c26887dd7ec2
1 parent 5b79479 commit 170f4e2

11 files changed

Lines changed: 145 additions & 24 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
feat(mcp): emit `$mcp_protocol_version` on MCP analytics events — the MCP spec version, recovered from the session token across stateless pods (parity with the TypeScript SDK). `PostHogMCP` capture methods gain a `protocol_version` argument.

posthog/mcp/_capture.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def capture_event(
4949
"server_version": data.server_version,
5050
"client_name": event_input.get("client_name"),
5151
"client_version": event_input.get("client_version"),
52+
"protocol_version": event_input.get("protocol_version"),
5253
"identify_actor_given_id": actor.distinct_id if actor else None,
5354
"identify_actor_data": (actor.properties or {}) if actor else {},
5455
"groups": actor.groups if actor else None,

posthog/mcp/_instrument_fastmcp.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,12 @@ async def wrapped(
9191
convert_result: bool = False,
9292
) -> Any:
9393
client_name, client_version = _client_info(context)
94+
protocol_version = _protocol_version(context)
9495
mcp_session_id = _mcp_session_id(context)
95-
token, client_name, client_version = resolve_session_and_client(
96-
mcp_session_id, client_name, client_version
96+
token, client_name, client_version, protocol_version = (
97+
resolve_session_and_client(
98+
mcp_session_id, client_name, client_version, protocol_version
99+
)
97100
)
98101
request = build_tool_call_request(name, arguments)
99102
extra: Dict[str, Any] = {"session_id": mcp_session_id}
@@ -103,6 +106,7 @@ async def wrapped(
103106
mcp_session_id=mcp_session_id,
104107
client_name=client_name,
105108
client_version=client_version,
109+
protocol_version=protocol_version,
106110
request=request,
107111
extra=extra,
108112
token=token,
@@ -118,6 +122,7 @@ async def wrapped(
118122
arguments=arguments,
119123
client_name=client_name,
120124
client_version=client_version,
125+
protocol_version=protocol_version,
121126
extra=extra,
122127
)
123128
return [
@@ -162,6 +167,7 @@ async def wrapped(
162167
duration_ms=(time.monotonic() - start) * 1000,
163168
client_name=client_name,
164169
client_version=client_version,
170+
protocol_version=protocol_version,
165171
conversation_id=None if minted else conversation_id,
166172
extra=extra,
167173
)
@@ -187,6 +193,7 @@ async def wrapped(
187193
duration_ms=(time.monotonic() - start) * 1000,
188194
client_name=client_name,
189195
client_version=client_version,
196+
protocol_version=protocol_version,
190197
conversation_id=delivered_conversation_id,
191198
extra=extra,
192199
)
@@ -215,9 +222,12 @@ async def list_handler(req: Any) -> Any:
215222
return await original(req)
216223

217224
client_name, client_version = _low_level_client_info(server)
225+
protocol_version = _low_level_protocol_version(server)
218226
mcp_session_id = _low_level_session_id(server)
219-
token, client_name, client_version = resolve_session_and_client(
220-
mcp_session_id, client_name, client_version
227+
token, client_name, client_version, protocol_version = (
228+
resolve_session_and_client(
229+
mcp_session_id, client_name, client_version, protocol_version
230+
)
221231
)
222232
request = request_to_dict(req)
223233
extra: Dict[str, Any] = {"session_id": mcp_session_id}
@@ -228,6 +238,7 @@ async def list_handler(req: Any) -> Any:
228238
mcp_session_id=mcp_session_id,
229239
client_name=client_name,
230240
client_version=client_version,
241+
protocol_version=protocol_version,
231242
request=request,
232243
extra=extra,
233244
token=token,
@@ -247,6 +258,7 @@ async def list_handler(req: Any) -> Any:
247258
error=error,
248259
client_name=client_name,
249260
client_version=client_version,
261+
protocol_version=protocol_version,
250262
extra=extra,
251263
)
252264
raise
@@ -299,6 +311,7 @@ async def list_handler(req: Any) -> Any:
299311
error="tools/list returned no tools" if empty else None,
300312
client_name=client_name,
301313
client_version=client_version,
314+
protocol_version=protocol_version,
302315
extra=extra,
303316
)
304317

@@ -389,6 +402,17 @@ def _low_level_session_id(server: Any) -> Optional[str]:
389402
return None
390403

391404

405+
def _low_level_protocol_version(server: Any) -> Optional[str]:
406+
ctx = _low_level_request_context(server)
407+
try:
408+
client_params = ctx.session.client_params
409+
if client_params:
410+
return client_params.protocolVersion
411+
except Exception: # noqa: BLE001
412+
pass
413+
return None
414+
415+
392416
def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]:
393417
try:
394418
client_params = context.request_context.session.client_params
@@ -399,6 +423,16 @@ def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]:
399423
return None, None
400424

401425

426+
def _protocol_version(context: Any) -> Optional[str]:
427+
try:
428+
client_params = context.request_context.session.client_params
429+
if client_params:
430+
return client_params.protocolVersion
431+
except Exception: # noqa: BLE001
432+
pass
433+
return None
434+
435+
402436
def _mcp_session_id(context: Any) -> Optional[str]:
403437
"""Best-effort transport session id (e.g. the ``Mcp-Session-Id`` header on the
404438
streamable-HTTP transport). Returns ``None`` for stdio, where the SDK-generated

posthog/mcp/_instrument_lowlevel.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,12 @@ async def handler(req: Any) -> Any:
9494
name = req.params.name
9595
arguments = dict(req.params.arguments or {})
9696
client_name, client_version = _client_info(server)
97+
protocol_version = _protocol_version(server)
9798
mcp_session_id = _mcp_session_id(server)
98-
token, client_name, client_version = resolve_session_and_client(
99-
mcp_session_id, client_name, client_version
99+
token, client_name, client_version, protocol_version = (
100+
resolve_session_and_client(
101+
mcp_session_id, client_name, client_version, protocol_version
102+
)
100103
)
101104
request = build_tool_call_request(name, arguments)
102105
extra = {"session_id": mcp_session_id}
@@ -106,6 +109,7 @@ async def handler(req: Any) -> Any:
106109
mcp_session_id=mcp_session_id,
107110
client_name=client_name,
108111
client_version=client_version,
112+
protocol_version=protocol_version,
109113
request=request,
110114
extra=extra,
111115
token=token,
@@ -121,6 +125,7 @@ async def handler(req: Any) -> Any:
121125
arguments=arguments,
122126
client_name=client_name,
123127
client_version=client_version,
128+
protocol_version=protocol_version,
124129
extra=extra,
125130
)
126131
return mcp_types.ServerResult(
@@ -169,6 +174,7 @@ async def handler(req: Any) -> Any:
169174
duration_ms=(time.monotonic() - start) * 1000,
170175
client_name=client_name,
171176
client_version=client_version,
177+
protocol_version=protocol_version,
172178
conversation_id=None if minted else conversation_id,
173179
extra=extra,
174180
)
@@ -203,6 +209,7 @@ async def handler(req: Any) -> Any:
203209
duration_ms=duration_ms,
204210
client_name=client_name,
205211
client_version=client_version,
212+
protocol_version=protocol_version,
206213
conversation_id=delivered_conversation_id,
207214
extra=extra,
208215
)
@@ -227,9 +234,12 @@ async def handler(req: Any) -> Any:
227234
return await original(req)
228235

229236
client_name, client_version = _client_info(server)
237+
protocol_version = _protocol_version(server)
230238
mcp_session_id = _mcp_session_id(server)
231-
token, client_name, client_version = resolve_session_and_client(
232-
mcp_session_id, client_name, client_version
239+
token, client_name, client_version, protocol_version = (
240+
resolve_session_and_client(
241+
mcp_session_id, client_name, client_version, protocol_version
242+
)
233243
)
234244
request = request_to_dict(req)
235245
extra = {"session_id": mcp_session_id}
@@ -240,6 +250,7 @@ async def handler(req: Any) -> Any:
240250
mcp_session_id=mcp_session_id,
241251
client_name=client_name,
242252
client_version=client_version,
253+
protocol_version=protocol_version,
243254
request=request,
244255
extra=extra,
245256
token=token,
@@ -259,6 +270,7 @@ async def handler(req: Any) -> Any:
259270
error=error,
260271
client_name=client_name,
261272
client_version=client_version,
273+
protocol_version=protocol_version,
262274
extra=extra,
263275
)
264276
raise
@@ -317,6 +329,7 @@ async def handler(req: Any) -> Any:
317329
error="tools/list returned no tools" if empty else None,
318330
client_name=client_name,
319331
client_version=client_version,
332+
protocol_version=protocol_version,
320333
extra=extra,
321334
)
322335

@@ -368,6 +381,17 @@ def _client_info(server: Any) -> Tuple[Optional[str], Optional[str]]:
368381
return None, None
369382

370383

384+
def _protocol_version(server: Any) -> Optional[str]:
385+
ctx = _request_context(server)
386+
try:
387+
client_params = ctx.session.client_params
388+
if client_params:
389+
return client_params.protocolVersion
390+
except Exception: # noqa: BLE001
391+
pass
392+
return None
393+
394+
371395
def _mcp_session_id(server: Any) -> Optional[str]:
372396
ctx = _request_context(server)
373397
try:

posthog/mcp/_instrumentation.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ async def _maybe_emit_initialize(
151151
client_name: Optional[str],
152152
client_version: Optional[str],
153153
extra: Optional[Dict[str, Any]],
154+
protocol_version: Optional[str] = None,
154155
) -> None:
155156
"""Lazily emit ``$mcp_initialize`` once per session. The Python MCP SDK handles
156157
``InitializeRequest`` inside the session layer (not ``request_handlers``), so we
@@ -163,6 +164,7 @@ async def _maybe_emit_initialize(
163164
"session_id": session_id,
164165
"client_name": client_name,
165166
"client_version": client_version,
167+
"protocol_version": protocol_version,
166168
"timestamp": datetime.now(timezone.utc),
167169
}
168170
await _apply_event_properties(
@@ -188,18 +190,21 @@ def resolve_session_and_client(
188190
raw_session_id: Optional[str],
189191
client_name: Optional[str],
190192
client_version: Optional[str],
191-
) -> tuple[Optional[SessionTokenPayload], Optional[str], Optional[str]]:
193+
protocol_version: Optional[str] = None,
194+
) -> tuple[Optional[SessionTokenPayload], Optional[str], Optional[str], Optional[str]]:
192195
"""Decode a replayed ``Mcp-Session-Id`` value as a self-encoded session token,
193-
and backfill the client name/version from it when the live transport supplied
194-
none (the stateless-pod case, where ``initialize`` was never seen here).
196+
and backfill the client name/version/protocol version from it when the live
197+
transport supplied none (the stateless-pod case, where ``initialize`` was never
198+
seen here).
195199
196-
Returns ``(token, client_name, client_version)``; ``token`` is ``None`` when the
197-
header isn't one of our tokens (a plain transport UUID, JWT, or nothing)."""
200+
Returns ``(token, client_name, client_version, protocol_version)``; ``token`` is
201+
``None`` when the header isn't one of our tokens (a plain UUID, JWT, or nothing)."""
198202
token = decode_session_id(raw_session_id)
199203
if token is not None:
200204
client_name = client_name or token.client_name
201205
client_version = client_version or token.client_version
202-
return token, client_name, client_version
206+
protocol_version = protocol_version or token.protocol_version
207+
return token, client_name, client_version, protocol_version
203208

204209

205210
async def prepare_request(
@@ -211,6 +216,7 @@ async def prepare_request(
211216
request: Dict[str, Any],
212217
extra: Optional[Dict[str, Any]],
213218
token: Optional[SessionTokenPayload] = None,
219+
protocol_version: Optional[str] = None,
214220
) -> str:
215221
"""Resolve the session id, run identify, then lazily emit initialize. Returns
216222
the session id to stamp on the event for this request.
@@ -228,7 +234,9 @@ async def prepare_request(
228234
identify_event = await handle_identify(data, session_id, request, extra)
229235
if identify_event:
230236
fire_and_forget(capture_event(data, identify_event))
231-
await _maybe_emit_initialize(data, session_id, client_name, client_version, extra)
237+
await _maybe_emit_initialize(
238+
data, session_id, client_name, client_version, extra, protocol_version
239+
)
232240
return session_id
233241

234242

@@ -243,6 +251,7 @@ async def record_tool_call(
243251
duration_ms: Optional[float] = None,
244252
client_name: Optional[str] = None,
245253
client_version: Optional[str] = None,
254+
protocol_version: Optional[str] = None,
246255
conversation_id: Optional[str] = None,
247256
extra: Optional[Dict[str, Any]] = None,
248257
) -> None:
@@ -260,6 +269,7 @@ async def record_tool_call(
260269
"duration": duration_ms,
261270
"client_name": client_name,
262271
"client_version": client_version,
272+
"protocol_version": protocol_version,
263273
"conversation_id": conversation_id,
264274
"is_error": False,
265275
}
@@ -341,6 +351,7 @@ async def record_missing_capability(
341351
arguments: Optional[Dict[str, Any]],
342352
client_name: Optional[str] = None,
343353
client_version: Optional[str] = None,
354+
protocol_version: Optional[str] = None,
344355
extra: Optional[Dict[str, Any]] = None,
345356
) -> None:
346357
"""Record a ``get_more_tools`` call as ``$mcp_missing_capability``, with the
@@ -354,6 +365,7 @@ async def record_missing_capability(
354365
"parameters": build_captured_mcp_parameters(request),
355366
"client_name": client_name,
356367
"client_version": client_version,
368+
"protocol_version": protocol_version,
357369
}
358370
if isinstance(context, str) and context.strip():
359371
event["user_intent"] = context.strip()
@@ -376,6 +388,7 @@ async def record_tools_list(
376388
error: Any = None,
377389
client_name: Optional[str] = None,
378390
client_version: Optional[str] = None,
391+
protocol_version: Optional[str] = None,
379392
extra: Optional[Dict[str, Any]] = None,
380393
) -> None:
381394
try:
@@ -388,6 +401,7 @@ async def record_tools_list(
388401
"duration": duration_ms,
389402
"client_name": client_name,
390403
"client_version": client_version,
404+
"protocol_version": protocol_version,
391405
"is_error": is_error,
392406
"timestamp": datetime.now(timezone.utc),
393407
}

posthog/mcp/_posthog_events.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
131131
properties[_P.CLIENT_NAME] = event["client_name"]
132132
if event.get("client_version"):
133133
properties[_P.CLIENT_VERSION] = event["client_version"]
134+
if event.get("protocol_version"):
135+
properties[_P.PROTOCOL_VERSION] = event["protocol_version"]
134136
if event.get("user_intent"):
135137
properties[_P.INTENT] = event["user_intent"]
136138
if event.get("user_intent_source"):
@@ -183,6 +185,8 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent:
183185
properties[_P.CLIENT_NAME] = event["client_name"]
184186
if event.get("client_version"):
185187
properties[_P.CLIENT_VERSION] = event["client_version"]
188+
if event.get("protocol_version"):
189+
properties[_P.PROTOCOL_VERSION] = event["protocol_version"]
186190

187191
_add_custom_properties(event, properties)
188192

posthog/mcp/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class PostHogMCPAnalyticsProperty:
5757

5858
CLIENT_NAME = "$mcp_client_name"
5959
CLIENT_VERSION = "$mcp_client_version"
60+
PROTOCOL_VERSION = "$mcp_protocol_version"
6061
CONVERSATION_ID = "$mcp_conversation_id"
6162
DURATION_MS = "$mcp_duration_ms"
6263
IS_ERROR = "$mcp_is_error"

0 commit comments

Comments
 (0)