Skip to content

Commit e9c490d

Browse files
committed
feat(mcp): uniform ctx in callbacks + exported get_request_headers
Adding v2 support left the callback contract divergent: the v2 adapters passed the request context as extra["ctx"] while the v1 adapters passed only session_id, so an identify() written against one major silently got nothing on the other — and on v1 there was no way to reach headers from extra at all. Header-based identification is the main use of identify, and the failure is invisible: no headers, no distinct id, every event anonymous. - all four adapters now pass the SDK's own per-request context as extra["ctx"], unchanged and identically shaped across majors - new exported get_request_headers(extra) flattens Starlette Headers, plain mappings, or any iterable of pairs into a lowercase-keyed dict; returns None on stdio and never raises - the context object still never reaches an event: the scalar projection added earlier for the $identify capture strips it Ports the intent of @posthog/mcp's getRequestHeaders (ADR-0006: hand hosts the raw context rather than synthesising a fake uniform shape, and give them one helper for the thing they actually need). Generated-By: PostHog Code Task-Id: ebafcb71-b03b-443d-b40c-d527ed4a04f4
1 parent 94ac113 commit e9c490d

9 files changed

Lines changed: 226 additions & 5 deletions

File tree

.sampo/changesets/mcp-sdk-v2-support.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
posthog: minor
33
---
44

5-
feat(mcp): support the MCP Python SDK 2.x and the 2026-07-28 spec revision. `instrument()` now wraps `mcp.server.mcpserver.MCPServer` (the renamed FastMCP) and the v2 low-level `Server` (constructor-injected handlers, string-keyed registry, late `add_request_handler` registrations included), capturing tool calls, tools/list, errors, intent, client identity, and `$mcp_protocol_version` on both protocol eras — SDK 1.x paths are unchanged. Conversation-anchored sessions land as the cross-pod correlation for the stateless era (parity with `@posthog/mcp`): with `enable_conversation_id`, `$session_id` is derived deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`), only handles the SDK could have minted (uuidv7) anchor, and the prompt-back now rides errored results too so a first-call failure keeps the conversation together. The handle is delivered over both channels a tool result has — a `content` text block on the minting response, and an `_mcp_instructions` key declared on the tool's `outputSchema` and mirrored into `structuredContent` on every response. The second channel is what makes the feature work for tools with structured output at all: clients that read `structuredContent` never render `content`, so the agent had no handle to echo (0% echo rate measured against Claude Code before the mirror). `instrument()` also no longer crashes on an unsupported or unrecognized MCP SDK — it degrades to a logged no-op.
5+
feat(mcp): support the MCP Python SDK 2.x and the 2026-07-28 spec revision. `instrument()` now wraps `mcp.server.mcpserver.MCPServer` (the renamed FastMCP) and the v2 low-level `Server` (constructor-injected handlers, string-keyed registry, late `add_request_handler` registrations included), capturing tool calls, tools/list, errors, intent, client identity, and `$mcp_protocol_version` on both protocol eras — SDK 1.x paths are unchanged. Conversation-anchored sessions land as the cross-pod correlation for the stateless era (parity with `@posthog/mcp`): with `enable_conversation_id`, `$session_id` is derived deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`), only handles the SDK could have minted (uuidv7) anchor, and the prompt-back now rides errored results too so a first-call failure keeps the conversation together. The handle is delivered over both channels a tool result has — a `content` text block on the minting response, and an `_mcp_instructions` key declared on the tool's `outputSchema` and mirrored into `structuredContent` on every response. The second channel is what makes the feature work for tools with structured output at all: clients that read `structuredContent` never render `content`, so the agent had no handle to echo (0% echo rate measured against Claude Code before the mirror). Host callbacks (`identify`, `intent_fallback`, `event_properties`, `before_send`) now receive the SDK's own per-request context as `extra["ctx"]` on both SDK majors, and a new exported `get_request_headers(extra)` reads HTTP headers off it — the shape differs between majors, and a hand-rolled read that works on one silently returns nothing on the other, which sends every event out anonymous. `instrument()` also no longer crashes on an unsupported or unrecognized MCP SDK — it degrades to a logged no-op.

posthog/mcp/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
)
5252
from .logger import log, set_logger
5353
from .posthog_mcp import PostHogMCP
54+
from .request_headers import get_request_headers
5455
from .session import (
5556
derive_session_id_from_conversation,
5657
derive_session_id_from_mcp_session,
@@ -88,6 +89,10 @@
8889
"CaptureEventData",
8990
"PreparedToolCall",
9091
"get_more_tools_result",
92+
# Read HTTP headers inside identify/intent_fallback/event_properties/
93+
# before_send callbacks on either SDK major: the raw per-request context
94+
# arrives as extra["ctx"] and its shape differs between them.
95+
"get_request_headers",
9196
"derive_session_id_from_mcp_session",
9297
# Conversation-anchored sessions: the cross-SDK derivation contract with
9398
# posthog-js (the 2026-07-28 revision has no protocol sessions, so the

posthog/mcp/_instrument_fastmcp.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,14 @@ async def wrapped(
103103
)
104104
)
105105
request = build_tool_call_request(name, arguments)
106-
extra: Dict[str, Any] = {"session_id": mcp_session_id}
106+
# `ctx` is the SDK's own per-request context, handed to host callbacks
107+
# unchanged and identically on both SDK majors (read headers off it with
108+
# the exported `get_request_headers`). Never captured — the event
109+
# pipeline keeps only a scalar projection of `extra`.
110+
extra: Dict[str, Any] = {
111+
"session_id": mcp_session_id,
112+
"ctx": getattr(context, "request_context", None),
113+
}
107114

108115
# Resolve the conversation handle before the session: when the agent
109116
# carries (or is about to receive) one, it anchors $session_id for every
@@ -249,7 +256,10 @@ async def list_handler(req: Any) -> Any:
249256
)
250257
)
251258
request = request_to_dict(req)
252-
extra: Dict[str, Any] = {"session_id": mcp_session_id}
259+
extra: Dict[str, Any] = {
260+
"session_id": mcp_session_id,
261+
"ctx": _low_level_request_context(server),
262+
}
253263
# Resolve session, emit $mcp_initialize (once per session) and identify here
254264
# too — a client may list tools without ever calling one.
255265
session_id = await prepare_request(

posthog/mcp/_instrument_lowlevel.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@ async def handler(req: Any) -> Any:
106106
)
107107
)
108108
request = build_tool_call_request(name, arguments)
109-
extra = {"session_id": mcp_session_id}
109+
# `ctx` is the SDK's own per-request context, handed to host callbacks
110+
# unchanged and identically on both SDK majors (read headers off it with
111+
# the exported `get_request_headers`). Never captured — the event
112+
# pipeline keeps only a scalar projection of `extra`.
113+
extra = {"session_id": mcp_session_id, "ctx": _request_context(server)}
110114

111115
# Resolve the conversation handle before the session: when present it
112116
# anchors $session_id for every event of this request (ADR-0004).
@@ -262,7 +266,11 @@ async def handler(req: Any) -> Any:
262266
)
263267
)
264268
request = request_to_dict(req)
265-
extra = {"session_id": mcp_session_id}
269+
# `ctx` is the SDK's own per-request context, handed to host callbacks
270+
# unchanged and identically on both SDK majors (read headers off it with
271+
# the exported `get_request_headers`). Never captured — the event
272+
# pipeline keeps only a scalar projection of `extra`.
273+
extra = {"session_id": mcp_session_id, "ctx": _request_context(server)}
266274
# Resolve session, emit $mcp_initialize (once per session) and identify here
267275
# too — a client may list tools without ever calling one.
268276
session_id = await prepare_request(

posthog/mcp/request_headers.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Portions of this package are derived from MCPCat/mcpcat-typescript-sdk
2+
# Copyright (c) 2025 MCPcat
3+
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
4+
5+
"""Read HTTP request headers inside a host callback, on either MCP SDK major.
6+
7+
``identify``, ``intent_fallback``, ``event_properties`` and ``before_send``
8+
receive the SDK's own per-request context under ``extra["ctx"]``, unchanged. We
9+
deliberately do not synthesise a uniform shape for it: the two majors expose
10+
different objects, and a fabricated one is a convincing partial lie about a
11+
shape the SDK actually changed. Headers are the one thing nearly every callback
12+
wants, so they get a helper instead::
13+
14+
from posthog.mcp import get_request_headers
15+
16+
def identify(request, extra):
17+
headers = get_request_headers(extra) or {}
18+
token = headers.get("authorization")
19+
...
20+
21+
Returns ``None`` when the request did not arrive over HTTP — stdio and
22+
in-memory transports carry no headers at all.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from typing import Any, Dict, Optional
28+
29+
__all__ = ["get_request_headers"]
30+
31+
RequestHeaderBag = Dict[str, str]
32+
33+
34+
def get_request_headers(extra: Any) -> Optional[RequestHeaderBag]:
35+
"""The request's HTTP headers as a plain dict with lowercase keys, or ``None``.
36+
37+
Accepts the ``extra`` dict handed to a callback, or the raw per-request
38+
context itself, so it works whichever one a host happens to hold.
39+
"""
40+
ctx = extra
41+
if isinstance(extra, dict):
42+
ctx = extra.get("ctx")
43+
if ctx is None:
44+
return None
45+
46+
# Both majors reach the transport's request the same way from their own
47+
# context object (`ServerRequestContext` on 2.x, `RequestContext` on 1.x);
48+
# `request` is None on stdio.
49+
source = getattr(getattr(ctx, "request", None), "headers", None)
50+
if source is None:
51+
return None
52+
return _to_header_bag(source)
53+
54+
55+
def _to_header_bag(source: Any) -> Optional[RequestHeaderBag]:
56+
"""Flatten a Starlette ``Headers``, a mapping, or anything iterable of pairs
57+
into a lowercase-keyed dict. Never raises: a header read must not take a
58+
tool call down with it."""
59+
try:
60+
# Starlette's Headers and dict both expose .items(); Headers already
61+
# lowercases, a plain dict may not, so normalise either way.
62+
items = source.items() if hasattr(source, "items") else source
63+
bag: RequestHeaderBag = {}
64+
for key, value in items:
65+
if isinstance(key, str) and isinstance(value, str):
66+
bag[key.lower()] = value
67+
return bag
68+
except Exception: # noqa: BLE001 - best effort, never break the tool path
69+
return None

posthog/test/mcp/test_fastmcp.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,35 @@ async def test_unsupported_server_returns_noop_handle():
205205
# graceful no-op: capture and flush do nothing and do not raise
206206
await handle.capture("anything")
207207
await handle.flush()
208+
209+
210+
async def test_callbacks_can_read_headers_through_the_helper():
211+
"""Same callback body as the v2 lane's equivalent test: `extra["ctx"]` is the
212+
SDK's own per-request context on both majors, read via `get_request_headers`."""
213+
from types import SimpleNamespace
214+
215+
from posthog.mcp import get_request_headers
216+
217+
server = make_server()
218+
client = FakeClient()
219+
seen = {}
220+
221+
def identify(request, extra):
222+
seen["headers"] = get_request_headers(extra)
223+
return None
224+
225+
instrument(server, client, MCPAnalyticsOptions(identify=identify))
226+
227+
# FastMCP hands the tool a Context whose .request_context carries the request.
228+
context = SimpleNamespace(
229+
request_context=SimpleNamespace(
230+
request=SimpleNamespace(headers={"Authorization": "Bearer t0ken"}),
231+
session=SimpleNamespace(client_params=None),
232+
)
233+
)
234+
await server._tool_manager.call_tool(
235+
"add", {"a": 1, "b": 1, "context": "header read"}, context=context
236+
)
237+
await _flush()
238+
239+
assert seen["headers"] == {"authorization": "Bearer t0ken"}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""``get_request_headers`` — reading HTTP headers inside a host callback.
2+
3+
Host callbacks receive the SDK's own per-request context under ``extra["ctx"]``,
4+
unchanged. Its shape differs between MCP SDK majors, so header reads go through
5+
this helper instead of a hand-rolled path that silently returns ``None`` on the
6+
other major (the failure mode is invisible: ``identify`` returns nothing and
7+
every event goes out anonymous). Runs under both majors.
8+
"""
9+
10+
from types import SimpleNamespace
11+
12+
from posthog.mcp import get_request_headers
13+
14+
15+
def _ctx(headers):
16+
return SimpleNamespace(request=SimpleNamespace(headers=headers))
17+
18+
19+
def test_reads_headers_from_a_callback_extra():
20+
extra = {"session_id": "abc", "ctx": _ctx({"Authorization": "Bearer t0ken"})}
21+
22+
assert get_request_headers(extra) == {"authorization": "Bearer t0ken"}
23+
24+
25+
def test_keys_are_lowercased():
26+
extra = {
27+
"ctx": _ctx({"X-Anthropic-Client": "claude-code", "USER-AGENT": "probe/1"})
28+
}
29+
30+
assert get_request_headers(extra) == {
31+
"x-anthropic-client": "claude-code",
32+
"user-agent": "probe/1",
33+
}
34+
35+
36+
def test_accepts_a_raw_context_too():
37+
"""A host holding the context itself shouldn't have to wrap it in a dict."""
38+
assert get_request_headers(_ctx({"a": "b"})) == {"a": "b"}
39+
40+
41+
def test_starlette_style_headers_are_supported():
42+
class Headers:
43+
"""Starlette's Headers: already-lowercased, .items() of pairs."""
44+
45+
def items(self):
46+
return [("content-type", "application/json"), ("mcp-session-id", "s1")]
47+
48+
assert get_request_headers({"ctx": _ctx(Headers())}) == {
49+
"content-type": "application/json",
50+
"mcp-session-id": "s1",
51+
}
52+
53+
54+
def test_stdio_and_missing_context_return_none():
55+
assert get_request_headers({"ctx": _ctx(None)}) is None # HTTP-less transport
56+
assert get_request_headers({"ctx": SimpleNamespace(request=None)}) is None
57+
assert get_request_headers({"session_id": "abc"}) is None # no ctx at all
58+
assert get_request_headers(None) is None
59+
60+
61+
def test_never_raises_on_a_hostile_header_object():
62+
class Exploding:
63+
def items(self):
64+
raise RuntimeError("nope")
65+
66+
assert get_request_headers({"ctx": _ctx(Exploding())}) is None
67+
68+
69+
def test_non_string_header_values_are_skipped():
70+
extra = {"ctx": _ctx({"good": "yes", "bad": 42, 7: "alsobad"})}
71+
72+
assert get_request_headers(extra) == {"good": "yes"}

posthog/test/mcp/test_v2_lowlevel.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,25 @@ async def test_report_missing_appends_virtual_tool():
247247
assert call_result.is_error is False
248248
missing = _events(client, "$mcp_missing_capability")
249249
assert missing and missing[0]["properties"]["$mcp_intent"] == "need an email tool"
250+
251+
252+
async def test_callbacks_can_read_headers_through_the_helper():
253+
"""The same `identify` body must work on both SDK majors: `extra["ctx"]` is
254+
the SDK's own context and `get_request_headers` normalises the read."""
255+
from posthog.mcp import get_request_headers
256+
257+
server = make_server()
258+
client = FakeClient()
259+
seen = {}
260+
261+
def identify(request, extra):
262+
seen["headers"] = get_request_headers(extra)
263+
return None
264+
265+
instrument(server, client, MCPAnalyticsOptions(identify=identify))
266+
267+
ctx = fake_ctx(headers={"Authorization": "Bearer t0ken", "User-Agent": "probe/1"})
268+
await _call_tool(server, "add", {"a": 1, "b": 1, "context": "header read"}, ctx=ctx)
269+
await _flush()
270+
271+
assert seen["headers"] == {"authorization": "Bearer t0ken", "user-agent": "probe/1"}

references/public_api_snapshot.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ alias posthog.mcp.derive_session_id_from_mcp_session -> posthog.mcp.session.deri
355355
alias posthog.mcp.encode_session_id -> posthog.mcp.session_token.encode_session_id
356356
alias posthog.mcp.get_mcp_session -> posthog.mcp.asgi.get_mcp_session
357357
alias posthog.mcp.get_more_tools_result -> posthog.mcp.tools.get_more_tools_result
358+
alias posthog.mcp.get_request_headers -> posthog.mcp.request_headers.get_request_headers
358359
alias posthog.mcp.set_logger -> posthog.mcp.logger.set_logger
359360
alias posthog.metrics_capture.VERSION -> posthog.version.VERSION
360361
alias posthog.metrics_capture.remove_trailing_slash -> posthog.utils.remove_trailing_slash
@@ -1129,6 +1130,7 @@ function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None
11291130
function posthog.mcp.asgi.get_mcp_session(request_or_scope: Any) -> Optional[SessionTokenPayload]
11301131
function posthog.mcp.instrument(server: Any, posthog_client: Optional[Client] = None, options: Optional[MCPAnalyticsOptions] = None) -> McpAnalytics
11311132
function posthog.mcp.logger.set_logger(logger: Optional[LoggerFn]) -> None
1133+
function posthog.mcp.request_headers.get_request_headers(extra: Any) -> Optional[RequestHeaderBag]
11321134
function posthog.mcp.session.derive_session_id_from_conversation(conversation_id: str) -> str
11331135
function posthog.mcp.session.derive_session_id_from_mcp_session(mcp_session_id: str) -> str
11341136
function posthog.mcp.session_token.decode_session_id(value: Any) -> Optional[SessionTokenPayload]
@@ -1423,6 +1425,7 @@ module posthog.mcp.asgi
14231425
module posthog.mcp.constants
14241426
module posthog.mcp.logger
14251427
module posthog.mcp.posthog_mcp
1428+
module posthog.mcp.request_headers
14261429
module posthog.mcp.session
14271430
module posthog.mcp.session_token
14281431
module posthog.mcp.tools

0 commit comments

Comments
 (0)