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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,44 @@ jobs:
run: |
pytest --verbose --timeout=30

tests-mcp-v2:
name: MCP SDK 2.x compatibility
runs-on: ubuntu-latest

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

- name: Set up Python 3.12
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- 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

# The `test` extra pins mcp<2; install the 2.x SDK over it so the same
# posthog/test/mcp suite runs against the 2026-07-28 SDK. jlowin's
# `fastmcp` (needs mcp 1.x) is uninstalled by this resolution, so its
# tests skip via `importorskip("fastmcp")`.
- name: Upgrade to MCP SDK 2.x
shell: bash
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv pip install "mcp>=2,<3"

- name: Run MCP tests against SDK 2.x
run: |
pytest posthog/test/mcp --verbose --timeout=30

mutation-tests:
name: Targeted mutation tests
runs-on: ubuntu-latest
Expand Down
108 changes: 79 additions & 29 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,33 +157,87 @@ 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)."""
"""PostHog MCP analytics supports two generations of the ``mcp`` SDK: 1.x
(``mcp>=1.26,<2``, the ``request_handlers`` seam) and 2.x (``mcp>=2,<3``, the
2026-07-28 ``ServerMiddleware`` seam). Since ``mcp`` is an unpinned peer
dependency, advise at runtime when the installed version is outside both
supported ranges rather than failing hard (a near-neighbor may still 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}. "
"Instrumentation hooks private SDK internals and may behave unexpectedly."
f"Warning: PostHog MCP analytics supports mcp>=1.26,<2 and mcp>=2,<3; found {installed}. "
"Instrumentation may behave unexpectedly on this version."
)


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, else the server
itself. v1 FastMCP (official and jlowin's) exposes ``_mcp_server``; the mcp 2.x
``MCPServer`` exposes ``_lowlevel_server``. 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


def _instrument_generation_1(
server: Any,
data: MCPAnalyticsData,
is_fastmcp: Any,
is_fastmcp_v2: Any,
is_low_level_server: Any,
) -> None:
"""Dispatch for the mcp 1.x SDK: the ``request_handlers`` monkey-patch seam
plus zero-config stateless minting (an ASGI wrap that is a no-op for stdio /
low-level servers)."""
from ._instrument_fastmcp import instrument_fastmcp
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level

if is_fastmcp(server):
instrument_fastmcp(server, data)
elif is_fastmcp_v2(server):
instrument_fastmcp_v2(server, data)
elif is_low_level_server(server):
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."
)

# Zero-config stateless minting: wrap the server's ASGI-app factories so a
# stateless/multi-pod deployment keeps one $session_id + the client harness
# across pods with no extra setup. No-op for stdio / low-level servers.
autowire_stateless_mint(server)


def _instrument_generation_2(server: Any, data: MCPAnalyticsData) -> None:
"""Dispatch for the mcp 2.x SDK (2026-07-28): attach the analytics
``ServerMiddleware`` to the ``MCPServer`` or low-level ``Server``. A capture-only
adapter — no context injection, no tool-list mutation, no stateless minting
(SEP-2567 removed the ``Mcp-Session-Id`` header, so there's nothing to mint)."""
from ._compatibility import is_low_level_server, is_mcpserver_v2
from ._instrument_v2 import instrument_low_level_v2, instrument_mcpserver_v2

if is_mcpserver_v2(server):
instrument_mcpserver_v2(server, data)
elif is_low_level_server(server):
instrument_low_level_v2(server, data)
else:
raise TypeError(
f"Unsupported server type for mcp 2.x: {type(server)!r}. Pass an "
"mcp.server.mcpserver.MCPServer or a low-level mcp.server.lowlevel.Server."
)


def instrument(
server: Any,
posthog_client: Optional[Client] = None,
Expand Down Expand Up @@ -222,10 +276,10 @@ def instrument(
"(PostHogMCP for custom dispatchers works without it.)"
)
_warn_if_unsupported_mcp_version()
from ._mcp_version import installed_mcp_generation
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

generation = installed_mcp_generation()
key = _canonical_server(server)

try:
Expand All @@ -241,24 +295,20 @@ def instrument(
data = MCPAnalyticsData(options=opts, sink=sink, session_id=new_session_id())
set_server_tracking_data(key, data)

if is_fastmcp(server):
instrument_fastmcp(server, data)
elif is_fastmcp_v2(server):
instrument_fastmcp_v2(server, data)
elif is_low_level_server(server):
instrument_low_level(server, data)
if generation == 2:
_instrument_generation_2(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."
_instrument_generation_1(
server, data, is_fastmcp, is_fastmcp_v2, is_low_level_server
)

# Zero-config stateless minting: wrap the server's ASGI-app factories so a
# stateless/multi-pod deployment keeps one $session_id + the client harness
# across pods with no extra setup. No-op for stdio / low-level servers.
autowire_stateless_mint(server)

return McpAnalytics(key)
except Exception as error: # noqa: BLE001
log(f"Warning: failed to instrument server - {error}")
# Degrade to a no-op so the host app keeps working, but make the failure
# actionable: name what happened, the detected generation, and the
# versions we support — never a bare ModuleNotFoundError or silent no-op.
log(
f"Warning: failed to instrument server (mcp generation {generation}, "
f"supported: 1.x as mcp>=1.26,<2 and 2.x as mcp>=2,<3) - {error}"
)
return _NoopAnalytics()
2 changes: 2 additions & 0 deletions posthog/mcp/_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def capture_event(
full_event: Dict[str, Any] = {
"id": event_input.get("id") or "",
"session_id": session_id,
"session_id_source": event_input.get("session_id_source"),
"event_type": event_input.get("event_type") or MCPAnalyticsEventType.CUSTOM,
"event_name": event_input.get("event_name"),
"timestamp": timestamp,
Expand All @@ -64,6 +65,7 @@ def capture_event(
"is_error": event_input.get("is_error"),
"error": event_input.get("error"),
"conversation_id": event_input.get("conversation_id"),
"result_type": event_input.get("result_type"),
"properties": event_input.get("properties"),
}

Expand Down
41 changes: 35 additions & 6 deletions posthog/mcp/_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,59 @@
# 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 check imports lazily and swallows import failures: the ``mcp`` SDK ships
two mutually incompatible generations under one package name (1.x removed
``mcp.server.fastmcp`` in 2.x, which added ``mcp.server.mcpserver.MCPServer``),
so a module-level import of either would crash ``instrument()`` on the other
generation. Each predicate returns ``False`` cleanly when its target class isn't
importable, letting ``instrument()`` dispatch on whichever generation is present.
"""

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 v1 SDK's high-level server (``mcp.server.fastmcp.FastMCP``). Returns
False on mcp 2.x, where that module was removed."""
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
return False
return isinstance(server, FastMCP)


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."""
from the official SDK. Its server layer needs mcp 1.x internals, so the import
raises under mcp 2.x — returns False there (and when ``fastmcp`` is absent)."""
try:
from fastmcp import FastMCP as FastMCPv2
except ImportError:
return False
return isinstance(server, FastMCPv2)


def is_mcpserver_v2(server: Any) -> bool:
"""The mcp 2.x high-level server (``mcp.server.mcpserver.MCPServer``), which
replaced ``FastMCP``. Returns False on mcp 1.x, where that module is absent."""
try:
from mcp.server.mcpserver import MCPServer
except ImportError:
return False
return isinstance(server, MCPServer)


def is_low_level_server(server: Any) -> bool:
"""A raw ``mcp.server.lowlevel.Server``. Present in both generations, but its
handler seam differs (v1: public ``request_handlers`` keyed by request type;
v2: private ``_request_handlers`` keyed by method string), so callers must
branch on generation before wrapping it."""
try:
from mcp.server.lowlevel import Server as LowLevelServer
except ImportError:
return False
return isinstance(server, LowLevelServer)
Loading
Loading