Skip to content

Commit 766e3bd

Browse files
committed
Merge branch 'posthog-code/mcp-error-properties' into posthog-code/mcp-client-attribution
2 parents b3e4360 + c61f8ff commit 766e3bd

5 files changed

Lines changed: 94 additions & 0 deletions

File tree

posthog/mcp/_instrument_fastmcp.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
build_tool_call_request,
4343
extract_tools,
4444
prepare_request,
45+
prime_session,
4546
read_tool_category,
4647
record_missing_capability,
4748
record_tool_call,
@@ -171,6 +172,10 @@ async def _session(anchor: Optional[str]) -> str:
171172
k: v for k, v in arguments.items() if k not in strip_keys
172173
}
173174

175+
# Settle the shared session before the tool body runs, so an in-tool
176+
# `analytics.capture()` is attributed to this caller and not the last one.
177+
await prime_session(data, mcp_session_id=mcp_session_id, token=token)
178+
174179
start = time.monotonic()
175180
try:
176181
result = await original(

posthog/mcp/_instrument_lowlevel.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
build_tool_call_request,
3838
extract_tools,
3939
prepare_request,
40+
prime_session,
4041
read_tool_category,
4142
record_missing_capability,
4243
record_tool_call,
@@ -178,6 +179,10 @@ async def _session(anchor: Optional[str]) -> str:
178179
if key not in owned:
179180
req.params.arguments.pop(key, None)
180181

182+
# Settle the shared session before the tool body runs, so an in-tool
183+
# `analytics.capture()` is attributed to this caller and not the last one.
184+
await prime_session(data, mcp_session_id=mcp_session_id, token=token)
185+
181186
start = time.monotonic()
182187
try:
183188
result = await original(req)

posthog/mcp/_instrument_v2.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
build_tool_call_request,
5252
params_to_request_dict,
5353
prepare_request,
54+
prime_session,
5455
read_tool_category,
5556
record_missing_capability,
5657
record_tool_call,
@@ -324,6 +325,10 @@ async def _session(anchor: Optional[str]) -> str:
324325
k: v for k, v in arguments.items() if k not in strip_keys
325326
}
326327

328+
# Settle the shared session before the tool body runs, so an in-tool
329+
# `analytics.capture()` is attributed to this caller and not the last one.
330+
await prime_session(data, mcp_session_id=mcp_session_id, token=token)
331+
327332
start = time.monotonic()
328333
try:
329334
result = await original(
@@ -488,6 +493,10 @@ async def _session(anchor: Optional[str]) -> str:
488493
]
489494
)
490495

496+
# Settle the shared session before the tool body runs, so an in-tool
497+
# `analytics.capture()` is attributed to this caller and not the last one.
498+
await prime_session(data, mcp_session_id=mcp_session_id, token=token)
499+
491500
start = time.monotonic()
492501
try:
493502
result = await original(ctx, params)

posthog/mcp/_instrumentation.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,24 @@ def resolve_session_and_client(
250250
return token, client_name, client_version, protocol_version
251251

252252

253+
async def prime_session(
254+
data: MCPAnalyticsData,
255+
*,
256+
mcp_session_id: Optional[str],
257+
token: Optional[SessionTokenPayload] = None,
258+
) -> None:
259+
"""Point the shared per-server session at *this* request before the tool body runs.
260+
261+
``McpAnalytics.capture()`` reads ``data.session_id`` for custom in-tool
262+
events. The conversation anchor can only be resolved after the call (we
263+
don't know until then whether the agent received the handle), so without
264+
this the tool body would read whatever the *previous* request left behind
265+
and attribute a custom event to the wrong caller. Emits nothing — it only
266+
settles the transport/memory session an in-tool event should belong to.
267+
"""
268+
await resolve_session_id(data, mcp_session_id, token=token)
269+
270+
253271
async def prepare_request(
254272
data: MCPAnalyticsData,
255273
*,

posthog/test/mcp/test_conversation_session.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,60 @@ def boom() -> str:
295295
)
296296
# and nothing claims a conversation the agent never received
297297
assert all("$mcp_conversation_id" not in e["properties"] for e in client.events)
298+
299+
300+
@pytest.mark.skipif(MCP_MAJOR != 1, reason="v1 FastMCP server")
301+
async def test_in_tool_events_are_not_attributed_to_the_previous_caller():
302+
"""A custom event captured *inside* a tool body reads the shared
303+
``data.session_id``. The conversation anchor can only be resolved after the
304+
call, so unless that field is settled first, caller B's in-tool event is
305+
attributed to caller A's session — and, through the identity cache, to
306+
caller A's person."""
307+
from types import SimpleNamespace
308+
309+
from mcp.server.fastmcp import FastMCP
310+
311+
from posthog.mcp import instrument
312+
from posthog.mcp._internal import get_server_tracking_data
313+
from posthog.mcp.session import derive_session_id_from_mcp_session
314+
315+
def caller(session_header):
316+
return SimpleNamespace(
317+
request_context=SimpleNamespace(
318+
request=SimpleNamespace(headers={"mcp-session-id": session_header}),
319+
session=SimpleNamespace(client_params=None),
320+
)
321+
)
322+
323+
server = FastMCP("in-tool")
324+
seen = {}
325+
326+
@server.tool()
327+
def echo(msg: str) -> str:
328+
return msg
329+
330+
client = FakeClient()
331+
handle = instrument(server, client, MCPAnalyticsOptions())
332+
333+
@server.tool()
334+
def emits(msg: str) -> str:
335+
data = get_server_tracking_data(handle._key)
336+
seen["session_during_body"] = data.session_id if data else None
337+
return msg
338+
339+
# Caller A runs first and leaves its session behind on the shared state.
340+
await server._tool_manager.call_tool(
341+
"echo", {"msg": "a", "context": "caller A"}, context=caller("session-A")
342+
)
343+
# Caller B's tool body must see *its own* session, not A's.
344+
await server._tool_manager.call_tool(
345+
"emits", {"msg": "b", "context": "caller B"}, context=caller("session-B")
346+
)
347+
await _flush()
348+
349+
assert seen["session_during_body"] == derive_session_id_from_mcp_session(
350+
"session-B"
351+
)
352+
assert seen["session_during_body"] != derive_session_id_from_mcp_session(
353+
"session-A"
354+
)

0 commit comments

Comments
 (0)