Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/mcp-meta-client-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

feat(mcp): capture client identity under the MCP 2026-07-28 revision, which removes the `initialize` handshake and carries the client name/version and protocol version in every request's `_meta` instead. `$mcp_client_name`, `$mcp_client_version`, and `$mcp_protocol_version` keep populating on both `mcp` 1.x (where the SDK ignores `_meta`, so we read it ourselves) and `mcp>=2` (where the SDK renamed the seams we read identity from). Legacy clients are unaffected. Also fixes tool-error detection on `mcp>=2`, where `CallToolResult.isError` is spelled `is_error` — without it every v2 tool error was recorded as a success. On `mcp>=2`, `tools/list` is not yet captured: that release replaces the `request_handlers` dispatch the listing seam hooks.
17 changes: 13 additions & 4 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,30 @@ def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]:

def _warn_if_unsupported_mcp_version() -> None:
"""The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``,
``request_handlers``) tested against ``mcp>=1.26,<2``. Since ``mcp`` is a peer
``request_handlers``) tested against ``mcp>=1.26,<3``. Since ``mcp`` is a peer
dependency we don't pin, advise at runtime when the installed version is outside
that range rather than failing hard (older/newer may still mostly work)."""
that range rather than failing hard (older/newer may still mostly work).

On ``mcp>=2`` tool calls and client identity are captured, but ``tools/list``
is not: that release replaces the ``request_handlers`` dispatch the listing
seam hooks."""
try:
from importlib.metadata import version

installed = version("mcp")
major, minor = (int(p) for p in installed.split(".")[:2])
except Exception: # noqa: BLE001 - never let a version probe break instrument()
return
if (major, minor) < (1, 26) or major >= 2:
if (major, minor) < (1, 26) or major >= 3:
log(
f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<2; found {installed}. "
f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<3; found {installed}. "
"Instrumentation hooks private SDK internals and may behave unexpectedly."
)
elif major >= 2:
log(
f"Note: on mcp {installed}, tool calls are captured but tools/list is not "
"(that release replaces the request_handlers dispatch)."
)


def _canonical_server(server: Any) -> Any:
Expand Down
58 changes: 58 additions & 0 deletions posthog/mcp/_client_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Client identity under the MCP 2026-07-28 (stateless) revision.

That revision removes the ``initialize`` handshake and the ``Mcp-Session-Id``
header (SEP-2575 / SEP-2567), so the client name/version and the protocol
version travel in every request's ``params._meta`` under the reverse-DNS keys
below instead of arriving once at ``initialize``.

We spell the keys out rather than import them: ``mcp>=2`` exports them as
``CLIENT_INFO_META_KEY`` / ``PROTOCOL_VERSION_META_KEY``, but ``mcp`` is an
optional peer dependency here (``PostHogMCP`` works without it) and 1.x has no
such constants. The strings match the 2026-07-28 schema either way.
"""

from __future__ import annotations

from typing import Any, Optional, Tuple

META_CLIENT_INFO_KEY = "io.modelcontextprotocol/clientInfo"
META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion"


def _str(value: Any) -> Optional[str]:
"""Non-empty strings only, so a client sending ``""`` or a non-string can't
blank out (or put junk over) a good value from the token or ``initialize``."""
return value if isinstance(value, str) and value else None


def apply_meta_client_info(
source: Any,
client_name: Optional[str],
client_version: Optional[str],
protocol_version: Optional[str],
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Layer any client identity from this request's ``_meta`` over values already
resolved from the transport or the session token.

``_meta`` wins: under the 2026-07-28 revision there is no ``initialize`` and
no session token to learn from, so it is the only per-request truth. Only the
fields the request actually carries override, so a legacy request (no
``_meta``) leaves all three untouched."""
try:
# A RequestContext carries `_meta` directly; a request nests it under
# `params`. `dict()` normalizes both SDK generations: `mcp>=2` hands over
# a plain dict, 1.x a `RequestParams.Meta` model whose undeclared
# reverse-DNS keys iterate out just the same.
params = getattr(source, "params", None)
meta = dict(
getattr(source, "meta", None) or getattr(params, "meta", None) or {}
)

client_info = meta.get(META_CLIENT_INFO_KEY) or {}
return (
_str(client_info.get("name")) or client_name,
_str(client_info.get("version")) or client_version,
_str(meta.get(META_PROTOCOL_VERSION_KEY)) or protocol_version,
)
except Exception: # noqa: BLE001 - `_meta` is client-controlled; never fatal
return client_name, client_version, protocol_version
19 changes: 16 additions & 3 deletions posthog/mcp/_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,26 @@

from typing import Any

from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel import Server as LowLevelServer

# The official SDK's high-level server, under both names it has shipped under:
# `mcp.server.fastmcp.FastMCP` on 1.x, renamed `mcp.server.mcpserver.MCPServer`
# in mcp>=2. Only one of these resolves on any given install, so import both
# tolerantly — a hard import of either breaks `posthog.mcp` on the other major.
_HIGH_LEVEL_SERVERS: tuple = ()
for _module, _name in (
("mcp.server.fastmcp", "FastMCP"),
("mcp.server.mcpserver", "MCPServer"),
):
try:
_HIGH_LEVEL_SERVERS += (getattr(__import__(_module, fromlist=[_name]), _name),)
except Exception: # noqa: BLE001 - absent on the other major version
pass


def is_fastmcp(server: Any) -> bool:
"""The official SDK's high-level server (``mcp.server.fastmcp.FastMCP``)."""
return isinstance(server, FastMCP)
"""The official SDK's high-level server, on either major version."""
return bool(_HIGH_LEVEL_SERVERS) and isinstance(server, _HIGH_LEVEL_SERVERS)


def is_fastmcp_v2(server: Any) -> bool:
Expand Down
11 changes: 6 additions & 5 deletions posthog/mcp/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ def _from_message(message: str) -> ErrorProperties:

def _is_call_tool_result(value: Any) -> bool:
"""Detect a CallToolResult error (``{isError, content: [...]}``), whether a
dict or a pydantic model from the ``mcp`` SDK."""
dict or a pydantic model from the ``mcp`` SDK. ``isError`` is spelled
``is_error`` on mcp>=2."""
if isinstance(value, dict):
return "isError" in value and isinstance(value.get("content"), list)
return hasattr(value, "isError") and isinstance(
getattr(value, "content", None), list
)
has_flag = "isError" in value or "is_error" in value
return has_flag and isinstance(value.get("content"), list)
has_flag = hasattr(value, "isError") or hasattr(value, "is_error")
return has_flag and isinstance(getattr(value, "content", None), list)


def _extract_call_tool_result_message(result: Any) -> str:
Expand Down
42 changes: 35 additions & 7 deletions posthog/mcp/_instrument_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ async def wrapped(
mcp_session_id = _mcp_session_id(context)
token, client_name, client_version, protocol_version = (
resolve_session_and_client(
mcp_session_id, client_name, client_version, protocol_version
mcp_session_id,
client_name,
client_version,
protocol_version,
meta_source=_context_request_context(context),
)
)
request = build_tool_call_request(name, arguments)
Expand Down Expand Up @@ -226,7 +230,11 @@ async def list_handler(req: Any) -> Any:
mcp_session_id = _low_level_session_id(server)
token, client_name, client_version, protocol_version = (
resolve_session_and_client(
mcp_session_id, client_name, client_version, protocol_version
mcp_session_id,
client_name,
client_version,
protocol_version,
meta_source=req,
)
)
request = request_to_dict(req)
Expand Down Expand Up @@ -413,21 +421,41 @@ def _low_level_protocol_version(server: Any) -> Optional[str]:
return None


def _context_request_context(context: Any) -> Any:
"""``Context.request_context`` raises off-request rather than returning
``None``, so a bare ``getattr`` default isn't enough."""
try:
return context.request_context
except Exception: # noqa: BLE001
return None


def _client_info(context: Any) -> Tuple[Optional[str], Optional[str]]:
try:
client_params = context.request_context.session.client_params
if client_params and client_params.clientInfo:
return client_params.clientInfo.name, client_params.clientInfo.version
# `clientInfo` on mcp 1.x; renamed `client_info` in mcp>=2, where the SDK
# synthesizes it from each request's `_meta` for us.
info = getattr(client_params, "clientInfo", None) or getattr(
client_params, "client_info", None
)
if info:
return info.name, info.version
except Exception: # noqa: BLE001
pass
return None, None


def _protocol_version(context: Any) -> Optional[str]:
try:
client_params = context.request_context.session.client_params
if client_params:
return client_params.protocolVersion
# mcp>=2 puts the negotiated version straight on the request context.
ctx = context.request_context
version = getattr(ctx, "protocol_version", None)
if version:
return version
client_params = ctx.session.client_params
return getattr(client_params, "protocolVersion", None) or getattr(
client_params, "protocol_version", None
)
except Exception: # noqa: BLE001
pass
return None
Expand Down
12 changes: 10 additions & 2 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ async def handler(req: Any) -> Any:
mcp_session_id = _mcp_session_id(server)
token, client_name, client_version, protocol_version = (
resolve_session_and_client(
mcp_session_id, client_name, client_version, protocol_version
mcp_session_id,
client_name,
client_version,
protocol_version,
meta_source=req,
)
)
request = build_tool_call_request(name, arguments)
Expand Down Expand Up @@ -238,7 +242,11 @@ async def handler(req: Any) -> Any:
mcp_session_id = _mcp_session_id(server)
token, client_name, client_version, protocol_version = (
resolve_session_and_client(
mcp_session_id, client_name, client_version, protocol_version
mcp_session_id,
client_name,
client_version,
protocol_version,
meta_source=req,
)
)
request = request_to_dict(req)
Expand Down
25 changes: 21 additions & 4 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from typing import Any, Dict, List, Optional, Set

from ._capture import capture_event
from ._client_identity import apply_meta_client_info
from ._event_types import MCPAnalyticsEventType
from ._exceptions import capture_exception
from ._intent import resolve_tool_call_intent, set_event_intent
Expand Down Expand Up @@ -108,10 +109,14 @@ def drain_pending_sync(timeout: Optional[float] = None) -> None:


def is_tool_result_error(result: Any) -> bool:
"""MCP tool results signal errors via ``isError: true`` rather than raising."""
"""MCP tool results signal errors via ``isError: true`` rather than raising
(spelled ``is_error`` on mcp>=2)."""
if isinstance(result, dict):
return result.get("isError") is True
return getattr(result, "isError", None) is True
return result.get("isError") is True or result.get("is_error") is True
return (
getattr(result, "isError", None) is True
or getattr(result, "is_error", None) is True
)


def build_tool_call_request(
Expand Down Expand Up @@ -191,20 +196,32 @@ def resolve_session_and_client(
client_name: Optional[str],
client_version: Optional[str],
protocol_version: Optional[str] = None,
meta_source: Any = None,
) -> tuple[Optional[SessionTokenPayload], Optional[str], Optional[str], Optional[str]]:
"""Decode a replayed ``Mcp-Session-Id`` value as a self-encoded session token,
and backfill the client name/version/protocol version from it when the live
transport supplied none (the stateless-pod case, where ``initialize`` was never
seen here).

``meta_source`` is anything carrying this request's ``params._meta`` (a
``Context``, ``RequestContext``, or request object). Under the MCP 2026-07-28
revision there is no ``initialize`` and no session token, so ``_meta`` is the
only source of client identity — it therefore takes precedence over both. For
legacy clients it is absent and nothing changes.

Returns ``(token, client_name, client_version, protocol_version)``; ``token`` is
``None`` when the header isn't one of our tokens (a plain UUID, JWT, or nothing)."""
token = decode_session_id(raw_session_id)
if token is not None:
client_name = client_name or token.client_name
client_version = client_version or token.client_version
protocol_version = protocol_version or token.protocol_version
return token, client_name, client_version, protocol_version
return (
token,
*apply_meta_client_info(
meta_source, client_name, client_version, protocol_version
),
)


async def prepare_request(
Expand Down
Loading