Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a52a78a
test(mcp): dual-major test setup — run the MCP suite against SDK v1 a…
gesh Aug 20, 2026
004c745
feat(mcp): support MCP Python SDK v2 and the 2026-07-28 spec revision
gesh Aug 20, 2026
9fda7cc
chore(mcp): refresh public API snapshot for the 0.3.0 sdk-surface bump
gesh Aug 20, 2026
021823f
fix(mcp): capture only a scalar projection of extra on $identify events
gesh Aug 20, 2026
fddfaf1
ci(mcp): name the MCP gate per SDK major — MCP SDK v1/v2 (Python X.Y)
gesh Aug 20, 2026
94ac113
fix(mcp): deliver the conversation handle via structuredContent too
gesh Aug 20, 2026
e9c490d
feat(mcp): uniform ctx in callbacks + exported get_request_headers
gesh Aug 20, 2026
5ed2086
fix(mcp): review findings — never mutate the caller's result, guard c…
gesh Aug 20, 2026
07dd1c8
refactor: apply simplify pass
gesh Aug 20, 2026
e335876
fix(mcp): finish the tool-cache fix on the low-level adapter
gesh Aug 20, 2026
45f5630
fix(mcp): don't require a parameter we strip before the SDK validates
gesh Aug 20, 2026
d7f2f39
Merge remote-tracking branch 'origin/main' into posthog-code/mcp-sdk-v2
gesh Aug 20, 2026
f8f4c1a
docs(mcp): reframe the changeset around cross-SDK parity
gesh Aug 21, 2026
3c14ab0
feat(mcp): emit $mcp_error_message and $mcp_error_type
gesh Aug 21, 2026
c988e85
fix(mcp): sanitize exception messages before they leave
gesh Aug 21, 2026
fb4e0ff
docs(mcp): state the real redaction scope on exception values
gesh Aug 21, 2026
ff81024
fix(mcp): only anchor a conversation handle the agent has confirmed
gesh Aug 21, 2026
68d8727
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
36a1e7d
fix(mcp): carry the conversation handle as data, not an instruction
gesh Aug 21, 2026
1485c11
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
d76ac48
fix(mcp): anchor the minting call too, once delivery is confirmed
gesh Aug 21, 2026
7ab0498
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
80c9c35
fix(mcp): settle the shared session before the tool body runs
gesh Aug 21, 2026
c61f8ff
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
8b7e06c
fix(mcp): redact non-PostHog credentials in captured strings
gesh Aug 21, 2026
c86b06d
Merge branch 'main' into posthog-code/mcp-error-properties
gesh Aug 21, 2026
c5542de
Merge remote-tracking branch 'origin/main' into posthog-code/mcp-erro…
gesh Aug 21, 2026
de4250f
chore(mcp): drop the changeset already consumed by v7.40.0
gesh Aug 21, 2026
c47606f
Merge branch 'posthog-code/mcp-error-properties' of https://github.co…
gesh Aug 21, 2026
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,51 @@ jobs:
run: |
pytest --verbose --timeout=30

tests-mcp:
# The MCP suite as a named gate per MCP Python SDK major. The v1 leg uses
# the lockfile's mcp 1.x (also exercised incidentally by the `tests`
# matrix — this leg exists as an explicit, named signal); the v2 leg
# (spec 2026-07-28) swaps in mcp>=2 and drops jlowin fastmcp, which pins
# mcp<2. posthog/test/mcp/conftest.py splits collection by major.
name: MCP SDK ${{ matrix.mcp-major }} (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.14']
mcp-major: ['v1', 'v2']

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.11.32"
enable-cache: true

- name: Install test dependencies
shell: bash
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test

- name: Swap in MCP SDK v2
if: matrix.mcp-major == 'v2'
shell: bash
run: |
uv pip uninstall --python $pythonLocation fastmcp
uv pip install --python $pythonLocation 'mcp>=2,<3'

- name: Run MCP tests against SDK ${{ matrix.mcp-major }}
run: |
pytest posthog/test/mcp --verbose --timeout=30

mutation-tests:
name: Targeted mutation tests
runs-on: ubuntu-latest
Expand Down
5 changes: 5 additions & 0 deletions .sampo/changesets/mcp-error-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
posthog: minor
---

feat(mcp): emit `$mcp_error_message` and `$mcp_error_type` on failed MCP events. The reason a tool call failed previously lived only on the sibling `$exception` event, so PostHog's failures view — which reads the scalars off the primary event — showed empty error rows for every Python-backed MCP server, and switching off `enable_exception_autocapture` removed the reason entirely. Both values are read from the same `$exception_list` the sibling carries, so the two surfaces can never disagree, and the message inherits the existing 2048-character cap. `PostHogMCP.capture_tool_call()` and `capture_tools_list()` take a new optional `error_type` for custom dispatchers that want a coarse category (`"validation"`, `"timeout"`) instead of the thrown class name. Parity with `@posthog/mcp`.
11 changes: 11 additions & 0 deletions .sampo/changesets/mcp-sdk-v2-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
posthog: minor
---

feat(mcp): support MCP Python SDK v2 and bring `posthog.mcp` to parity with the TypeScript SDK (`@posthog/mcp`). **Most of this reaches SDK 1.x servers too** — the parity work is not v2-only.

**MCP SDK v2 / spec 2026-07-28.** `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 — the legacy handshake and the stateless 2026-07-28 envelope, decided per request. Previously `instrument()` raised `ImportError` on `mcp>=2` and took the host application down with it; it now degrades to a logged no-op on any unsupported or unrecognized SDK.

**Cross-SDK parity (SDK 1.x and 2.x alike).** Conversation-anchored sessions land as the cross-pod correlation the stateless era needs: with `enable_conversation_id`, `$session_id` derives deterministically from the agent-echoed `conversation_id` (new export `derive_session_id_from_conversation`, byte-compatible with `@posthog/mcp`). Only a handle the SDK could have minted (a uuidv7) anchors a session, so two callers inventing the same id can no longer be merged. The handle is delivered over both channels a tool result has — a `content` text block carrying it as plain JSON data on the minting response (an imperative server sentence inside a tool result is prompt-injection-shaped, and a client that strips it silently breaks the feature), and an `_mcp_instructions` key declared on the tool's output schema and mirrored into `structuredContent` on every response. That second channel is what makes the feature work at all for tools with structured output: 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). The prompt-back now rides errored results too, so a failure on a conversation's first call doesn't split the retry into a new session. The session is resolved only once the handle's fate is known, so the call that mints a handle joins the same session as the calls that echo it — while a handle that could not be delivered anchors nothing, rather than stranding events in a conversation nobody holds. Host callbacks (`identify`, `intent_fallback`, `event_properties`) receive the SDK's own per-request context as `extra["ctx"]` identically on both majors, with a new exported `get_request_headers(extra)` to read HTTP headers off it — the underlying shape differs per major, and a hand-rolled read that works on one silently returns nothing on the other, sending every event out anonymous.

**Fixes affecting existing SDK 1.x users.** Analytics could break a tool call in three ways, each now fixed and regression-tested: the SDK's tool cache is rebuilt from an internal listing pass we skipped injecting on, so after any call to an unlisted tool name a strict schema rejected either the analytics parameters we advertise (`Input validation error`) or the conversation key we write (`Output validation error`); the conversation handle was written into the caller's result object in place, so a tool returning a shared or cached result served one conversation's handle to every later caller; and on jlowin's FastMCP the advertised schema marked `context` required while the adapter strips it before validation, failing every call under `strict_input_validation=True`. Two behavioural changes come with the parity work: an invented (non-uuidv7) `conversation_id` echo is replaced with a fresh handle rather than trusted, and minted prompt-backs are now appended to errored results.
3 changes: 3 additions & 0 deletions examples/mcp_analytics_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
import os

import mcp.types as mcp_types

# MCP SDK 1.x. On mcp>=2 the class moved: `from mcp.server.mcpserver import
# MCPServer` — instrument() works the same on both.
from mcp.server.fastmcp import FastMCP

from posthog import Posthog
Expand Down
98 changes: 74 additions & 24 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,23 @@

"""PostHog MCP analytics SDK — product analytics for Model Context Protocol servers.

Wrap a Python MCP server (``FastMCP`` or low-level ``mcp.server.Server``) so every
tool call, agent intent, and failure is captured to PostHog as a ``$mcp_*`` event::
Wrap a Python MCP server so every tool call, agent intent, and failure is
captured to PostHog as a ``$mcp_*`` event. Works with the MCP Python SDK 1.x
*and* 2.x (the 2026-07-28 spec revision) — the high-level server class moved
between majors, but ``instrument()`` is the same::

from posthog import Posthog
from posthog.mcp import instrument
from mcp.server.fastmcp import FastMCP

# MCP SDK 2.x (spec 2026-07-28)
from mcp.server.mcpserver import MCPServer
server = MCPServer("my-server")

# MCP SDK 1.x
# from mcp.server.fastmcp import FastMCP
# server = FastMCP("my-server")

posthog = Posthog("phc_...", host="https://us.i.posthog.com")
server = FastMCP("my-server")
analytics = instrument(server, posthog)

Install is just ``pip install posthog``. ``instrument()`` needs the MCP SDK at runtime,
Expand Down Expand Up @@ -43,7 +51,12 @@
)
from .logger import log, set_logger
from .posthog_mcp import PostHogMCP
from .session import derive_session_id_from_mcp_session, new_session_id
from .request_headers import get_request_headers
from .session import (
derive_session_id_from_conversation,
derive_session_id_from_mcp_session,
new_session_id,
)
from .session_token import (
MCP_SESSION_HEADER,
SessionTokenPayload,
Expand Down Expand Up @@ -76,7 +89,15 @@
"CaptureEventData",
"PreparedToolCall",
"get_more_tools_result",
# Read HTTP headers inside identify / intent_fallback /
# event_properties callbacks on either SDK major: the per-request context
# arrives as extra["ctx"] and its shape differs between them.
"get_request_headers",
"derive_session_id_from_mcp_session",
# Conversation-anchored sessions: the cross-SDK derivation contract with
# posthog-js (the 2026-07-28 revision has no protocol sessions, so the
# agent-echoed conversation_id is the only cross-pod session carrier).
"derive_session_id_from_conversation",
# Self-encoded session tokens for stateless / multi-pod servers. Minted onto
# the `Mcp-Session-Id` response header by PostHogMcpStatelessSessionMiddleware
# and decoded on every request; codec is exported for custom HTTP layers.
Expand Down Expand Up @@ -157,30 +178,35 @@ 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
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)."""
"""The adapters hook private MCP SDK seams (``_tool_manager``, ``_mcp_server``
/ ``_lowlevel_server``, the request-handler registries) tested against
``mcp>=1.26,<3`` — both the 1.x line and the 2.x line (spec 2026-07-28).
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)."""
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."
)


def _canonical_server(server: Any) -> Any:
"""The underlying low-level server for high-level wrappers (official FastMCP and
jlowin's fastmcp 2.0 both expose ``_mcp_server``), else the server itself. Used as
the tracking key so instrumenting a wrapper and its underlying server resolve to
one state instead of two divergent ones (matching the TS SDK)."""
low_level = getattr(server, "_mcp_server", None)
"""The underlying low-level server for high-level wrappers (SDK 1.x FastMCP and
jlowin's fastmcp expose ``_mcp_server``; SDK 2.x MCPServer renamed it
``_lowlevel_server``), else the server itself. Used as the tracking key so
instrumenting a wrapper and its underlying server resolve to one state instead
of two divergent ones (matching the TS SDK)."""
low_level = getattr(server, "_mcp_server", None) or getattr(
server, "_lowlevel_server", None
)
return low_level if low_level is not None else server


Expand All @@ -197,8 +223,9 @@ def instrument(
state instead of double-wrapping. Degrades to a no-op handle on any failure so
the host application keeps working.

:param server: A ``FastMCP`` server (official ``mcp.server.fastmcp`` or jlowin's
``fastmcp`` 2.0) or a low-level ``mcp.server.Server``.
:param server: A high-level server — SDK 1.x ``mcp.server.fastmcp.FastMCP``,
SDK 2.x ``mcp.server.mcpserver.MCPServer``, or jlowin's ``fastmcp.FastMCP``
— or a low-level ``mcp.server.lowlevel.Server`` (either SDK major).
:param posthog_client: A posthog ``Client`` you construct and own (call
``shutdown()`` on exit to flush). Falls back to the global client.
:param options: Optional :class:`MCPAnalyticsOptions`.
Expand All @@ -222,13 +249,20 @@ def instrument(
"(PostHogMCP for custom dispatchers works without it.)"
)
_warn_if_unsupported_mcp_version()
from ._compatibility import is_fastmcp, is_fastmcp_v2, is_low_level_server
from ._instrument_fastmcp import instrument_fastmcp
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level

key = _canonical_server(server)

try:
# Imported inside the try: the adapters touch major-specific modules, and
# an import error must degrade to the no-op handle, not crash the host.
from ._compatibility import (
is_fastmcp,
is_fastmcp_v2,
is_low_level_server,
is_mcpserver,
uses_v2_handler_registry,
)

client = _resolve_client(posthog_client)
if client is None:
log("Warning: no PostHog client available; MCP events will not be sent.")
Expand All @@ -242,15 +276,31 @@ def instrument(
set_server_tracking_data(key, data)

if is_fastmcp(server):
from ._instrument_fastmcp import instrument_fastmcp

instrument_fastmcp(server, data)
elif is_mcpserver(server):
from ._instrument_v2 import instrument_mcpserver_v2

instrument_mcpserver_v2(server, data)
elif is_fastmcp_v2(server):
from ._instrument_lowlevel import instrument_fastmcp_v2

instrument_fastmcp_v2(server, data)
elif is_low_level_server(server):
instrument_low_level(server, data)
if uses_v2_handler_registry(server):
from ._instrument_v2 import instrument_lowlevel_v2

instrument_lowlevel_v2(server, data)
else:
from ._instrument_lowlevel import instrument_low_level

instrument_low_level(server, data)
else:
raise TypeError(
f"Unsupported server type: {type(server)!r}. Pass a FastMCP (official or jlowin's "
"fastmcp 2.0) or a low-level mcp.server.Server."
f"Unsupported server type: {type(server)!r}. Pass a high-level server "
"(mcp.server.fastmcp.FastMCP on SDK 1.x, mcp.server.mcpserver.MCPServer "
"on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server."
)

# Zero-config stateless minting: wrap the server's ASGI-app factories so a
Expand Down
1 change: 1 addition & 0 deletions posthog/mcp/_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def capture_event(
"user_intent_source": event_input.get("user_intent_source"),
"is_error": event_input.get("is_error"),
"error": event_input.get("error"),
"error_type": event_input.get("error_type"),
"conversation_id": event_input.get("conversation_id"),
"properties": event_input.get("properties"),
}
Expand Down
48 changes: 43 additions & 5 deletions posthog/mcp/_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,40 @@
# Copyright (c) 2025 MCPcat
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE

"""Detect which kind of MCP server was passed to ``instrument()``."""
"""Detect which kind of MCP server was passed to ``instrument()``.

Every probe is import-tolerant: the classes live at different paths per MCP SDK
major (``mcp.server.fastmcp.FastMCP`` on 1.x, ``mcp.server.mcpserver.MCPServer``
on 2.x), so a probe whose class doesn't exist on the installed major answers
``False`` instead of raising — an unconditional import here is exactly what
would crash ``instrument()`` on the other major.
"""

from __future__ import annotations

from typing import Any

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


def is_fastmcp(server: Any) -> bool:
"""The official SDK's high-level server (``mcp.server.fastmcp.FastMCP``)."""
"""The MCP SDK 1.x high-level server (``mcp.server.fastmcp.FastMCP``).
The module was renamed in 2.x, so this is False whenever mcp>=2 is installed."""
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
return False
return isinstance(server, FastMCP)


def is_mcpserver(server: Any) -> bool:
"""The MCP SDK 2.x high-level server (``mcp.server.mcpserver.MCPServer``,
the renamed FastMCP). False whenever mcp<2 is installed."""
try:
from mcp.server.mcpserver import MCPServer
except ImportError:
return False
return isinstance(server, MCPServer)


def is_fastmcp_v2(server: Any) -> bool:
"""jlowin's standalone FastMCP 2.0 (``fastmcp.FastMCP``), a separate package
from the official SDK. Returns False if ``fastmcp`` isn't installed."""
Expand All @@ -28,4 +47,23 @@ def is_fastmcp_v2(server: Any) -> bool:


def is_low_level_server(server: Any) -> bool:
"""The low-level ``mcp.server.lowlevel.Server`` — the import path is the
same on both majors; use :func:`uses_v2_handler_registry` to tell which
handler seam it carries."""
try:
from mcp.server.lowlevel import Server as LowLevelServer
except ImportError:
return False
return isinstance(server, LowLevelServer)


def uses_v2_handler_registry(server: Any) -> bool:
"""Which major's handler seam a low-level server carries, decided by shape
rather than package version (a la posthog-js ADR-0005): 1.x exposes the
public ``request_handlers`` dict keyed by request class; 2.x replaced it
with ``add_request_handler``/``get_request_handler`` keyed by method string."""
if hasattr(server, "add_request_handler") and hasattr(
server, "get_request_handler"
):
return True
return not hasattr(server, "request_handlers")
11 changes: 11 additions & 0 deletions posthog/mcp/_context_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ def is_context_enabled(context: Union[bool, MCPAnalyticsContextOptions, None]) -
return context is not False


def schema_has_param(schema: Any, name: str) -> bool:
"""Whether a (already-serialized) JSON Schema dict declares a top-level
property named ``name``. Shared by the lowlevel and v2 adapters, which both
need to tell an injected parameter apart from one the tool already owns."""
return (
isinstance(schema, dict)
and isinstance(schema.get("properties"), dict)
and name in schema["properties"]
)


def get_context_description(
context: Union[bool, MCPAnalyticsContextOptions, None],
) -> Optional[str]:
Expand Down
Loading