Skip to content

Commit 021823f

Browse files
committed
fix(mcp): capture only a scalar projection of extra on $identify events
The v2 adapters hand the raw request ctx to identify/event_properties/ intent_fallback callbacks via `extra` so hosts can read headers — but handle_identify embedded the whole dict into the captured $identify parameters, where the sanitizer leaves opaque objects untouched and truncation stringifies them: whatever the context repr carries (headers, transport state) would ship to PostHog without key-based redaction. Callbacks keep the full extra; captured parameters now carry only JSON-safe scalars (e.g. session_id). Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4
1 parent 9fda7cc commit 021823f

2 files changed

Lines changed: 50 additions & 1 deletion

File tree

posthog/mcp/_internal.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,24 @@ async def _maybe_await(value: Any) -> Any:
132132
return value
133133

134134

135+
def _captured_extra(extra: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
136+
"""Project ``extra`` down to JSON-safe scalars before it is captured.
137+
138+
Callbacks (``identify``, ``event_properties``, ``intent_fallback``) receive
139+
the full dict — on MCP SDK v2 that includes the raw request ``ctx`` so hosts
140+
can read headers. Captured event parameters must not: the sanitizer leaves
141+
opaque objects untouched and truncation stringifies them, which would ship
142+
whatever the object's repr carries (headers, auth material, transport state)
143+
to PostHog without key-based redaction."""
144+
if extra is None:
145+
return None
146+
return {
147+
key: value
148+
for key, value in extra.items()
149+
if value is None or isinstance(value, (str, int, float, bool))
150+
}
151+
152+
135153
async def handle_identify(
136154
data: MCPAnalyticsData,
137155
session_id: str,
@@ -170,7 +188,7 @@ async def handle_identify(
170188
"session_id": session_id,
171189
"resource_name": _get_request_resource_name(request),
172190
"event_type": MCPAnalyticsEventType.IDENTIFY,
173-
"parameters": {"request": request, "extra": extra},
191+
"parameters": {"request": request, "extra": _captured_extra(extra)},
174192
"timestamp": datetime.now(timezone.utc),
175193
}
176194
except Exception as error: # noqa: BLE001

posthog/test/mcp/test_v2_mcpserver.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,37 @@ async def test_identify_sets_distinct_id_and_groups():
208208
assert _events(client, "$identify")
209209

210210

211+
async def test_identify_callback_sees_ctx_but_capture_does_not():
212+
"""The v2 adapters hand the raw request ``ctx`` to callbacks via ``extra`` so
213+
hosts can read headers — but the captured $identify parameters must carry
214+
only a scalar projection, or the stringified context (headers, transport
215+
state) would ship to PostHog without key-based redaction."""
216+
server = make_server()
217+
client = FakeClient()
218+
seen = {}
219+
220+
def identify(request, extra):
221+
seen["extra"] = extra
222+
return UserIdentity(distinct_id="user_9")
223+
224+
instrument(server, client, MCPAnalyticsOptions(identify=identify))
225+
226+
await _call_tool(
227+
server, "add", {"a": 1, "b": 1, "context": "identity capture check"}
228+
)
229+
await _flush()
230+
231+
# the callback got the live context object...
232+
assert seen["extra"]["ctx"] is not None
233+
234+
# ...but the captured event only carries JSON-safe scalars
235+
identify_events = _events(client, "$identify")
236+
assert identify_events
237+
captured_extra = identify_events[0]["properties"]["$mcp_parameters"]["extra"]
238+
assert "ctx" not in captured_extra
239+
assert set(captured_extra) <= {"session_id"}
240+
241+
211242
async def test_report_missing_advertises_and_captures():
212243
server = make_server()
213244
client = FakeClient()

0 commit comments

Comments
 (0)