Skip to content
Open
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-analytics-python-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'pypi/posthog': minor
---

Add `posthog.mcp`, a Python SDK for PostHog MCP analytics (just `pip install posthog`; the MCP SDK is a peer dependency of `instrument()`, not bundled). `instrument(server, posthog_client)` wraps a `FastMCP` or low-level `mcp.server.Server` so every tool call, agent intent, tools/list, initialize, and failure is captured to PostHog as a `$mcp_*` event. Also adds `PostHogMCP`, a `Client` subclass for custom dispatchers (needs nothing beyond posthog), plus opt-in `context` intent capture, `identify`, `report_missing` (`get_more_tools`), and `conversation_id`. Beta.
27 changes: 27 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ SOFTWARE.

---

Some files in this codebase contain code from MCPCat/mcpcat-typescript-sdk.
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.

MIT License

Copyright (c) 2025 MCPcat

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

---

Some files in this codebase contain code from getsentry/sentry-python.
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.

Expand Down
97 changes: 97 additions & 0 deletions examples/mcp_analytics_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Dogfood demo for the PostHog MCP analytics SDK.

Instruments a small FastMCP server and sends real ``$mcp_*`` events to PostHog so
you can watch them land in the MCP analytics dashboard.

Usage::

POSTHOG_PROJECT_API_KEY=phc_xxx python examples/mcp_analytics_demo.py
# optional: POSTHOG_HOST=https://us.i.posthog.com (default)

This drives the instrumented server's seams directly (tools/list + tool calls)
rather than spinning up a transport + client, so it's a self-contained way to
generate events.
"""

import asyncio
import os

import mcp.types as mcp_types
from mcp.server.fastmcp import FastMCP

from posthog import Posthog
from posthog.mcp import instrument
from posthog.mcp.types import MCPAnalyticsOptions, UserIdentity

API_KEY = os.environ.get("POSTHOG_PROJECT_API_KEY")
HOST = os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com")
SERVER_NAME = "posthog-python-mcp-demo"


def build_server() -> FastMCP:
server = FastMCP(SERVER_NAME)

@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b

@server.tool()
def divide(a: int, b: int) -> float:
"""Divide a by b."""
return a / b

return server


async def main() -> None:
if not API_KEY:
raise SystemExit(
"Set POSTHOG_PROJECT_API_KEY (a phc_ project key) to run the demo."
)

posthog = Posthog(API_KEY, host=HOST)
server = build_server()
analytics = instrument(
server,
posthog,
MCPAnalyticsOptions(
identify=lambda request, extra: UserIdentity(
distinct_id="python-sdk-dogfood",
properties={"source": "posthog-python mcp demo"},
),
),
)

# tools/list -> $mcp_tools_list (+ context injection)
list_handler = server._mcp_server.request_handlers[mcp_types.ListToolsRequest]
await list_handler(mcp_types.ListToolsRequest(method="tools/list"))

# tool calls -> $mcp_initialize (lazy, once), $identify, $mcp_tool_call x3, $exception
await server._tool_manager.call_tool(
"add",
{"a": 2, "b": 3, "context": "adding two numbers to demo the python mcp sdk"},
)
await server._tool_manager.call_tool(
"divide",
{"a": 10, "b": 2, "context": "dividing values to show a successful tool call"},
)
try:
await server._tool_manager.call_tool(
"divide",
{"a": 1, "b": 0, "context": "dividing by zero to exercise error capture"},
)
except Exception:
pass

# custom event via the handle
await analytics.capture("demo_feedback", {"rating": 5})

await analytics.flush() # await in-flight auto-capture tasks (no racy sleep)
posthog.flush()
posthog.shutdown()
print(f"Sent MCP analytics events for server '{SERVER_NAME}' to {HOST}")


if __name__ == "__main__":
asyncio.run(main())
231 changes: 231 additions & 0 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# Portions of this package are derived from MCPCat/mcpcat-typescript-sdk
# Copyright (c) 2025 MCPcat
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE
Comment on lines +1 to +3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in 31c854a. added the MCPCat block to LICENSE so it matches the file headers. thanks for catching it!


"""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::

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

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,
but anyone wrapping a server already has it (you built the server with it), so it's
treated as a peer dependency — imported lazily and version-checked inside ``instrument()``
rather than bundled. ``PostHogMCP`` for custom dispatchers needs nothing beyond posthog.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Optional

from posthog.client import Client

from .capture import capture_event
from .constants import (
POSTHOG_MCP_ANALYTICS_SOURCE,
PostHogMCPAnalyticsEvent,
PostHogMCPAnalyticsProperty,
)
from .event_types import MCPAnalyticsEventType
from .instrumentation import drain_pending
from .internal import (
MCPAnalyticsData,
get_server_tracking_data,
set_server_tracking_data,
)
from .logger import log, set_logger
from .posthog_mcp import PostHogMCP
from .session import derive_session_id_from_mcp_session, new_session_id
from .sink import McpEventSink
from .tools import get_more_tools_result
from .types import (
CaptureEventData,
MCPAnalyticsContextOptions,
MCPAnalyticsOptions,
PreparedToolCall,
UserIdentity,
)
from .version import __version__

__all__ = [
"instrument",
"McpAnalytics",
"PostHogMCP",
"MCPAnalyticsOptions",
"MCPAnalyticsContextOptions",
"UserIdentity",
"CaptureEventData",
"PreparedToolCall",
"get_more_tools_result",
"derive_session_id_from_mcp_session",
"set_logger",
"POSTHOG_MCP_ANALYTICS_SOURCE",
"PostHogMCPAnalyticsEvent",
"PostHogMCPAnalyticsProperty",
"__version__",
]


class McpAnalytics:
"""Handle returned by :func:`instrument`. Use it to capture custom events for
the instrumented server without passing the server object around."""

def __init__(self, key: Any) -> None:
self._key = key

async def capture(self, event: str, properties: Optional[dict] = None) -> None:
"""Capture a custom event for this server. ``event`` is sent verbatim (a
customer-defined event, so it is not ``$``-prefixed)."""
if not isinstance(event, str) or not event:
raise ValueError(
'capture() requires an event name, e.g. await analytics.capture("feedback_submitted")'
)
data = get_server_tracking_data(self._key)
if data is None:
return
coro = capture_event(
data,
{
"session_id": data.session_id,
"event_type": MCPAnalyticsEventType.CUSTOM,
"event_name": event,
"timestamp": datetime.now(timezone.utc),
"properties": properties,
},
)
if coro is not None:
await coro

async def flush(self) -> None:
"""Await in-flight auto-captured events scheduled on the current event loop.
Call this before ``posthog.shutdown()`` on exit so trailing tool-call events
aren't dropped. (Then call ``posthog.flush()``/``shutdown()`` to send them.)"""
await drain_pending()


class _NoopAnalytics(McpAnalytics):
def __init__(self) -> None: # noqa: D401 - graceful degradation handle
super().__init__(None)

async def capture(self, event: str, properties: Optional[dict] = None) -> None:
return None


def _resolve_client(posthog_client: Optional[Client]) -> Optional[Client]:
if posthog_client is not None:
return posthog_client
try:
from posthog import setup

return setup()
except Exception: # noqa: BLE001
return None


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)."""
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:
log(
f"Warning: PostHog MCP analytics is tested against mcp>=1.26,<2; 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)
return low_level if low_level is not None else server


def instrument(
server: Any,
posthog_client: Optional[Client] = None,
options: Optional[MCPAnalyticsOptions] = None,
) -> McpAnalytics:
"""Instrument an MCP server so PostHog auto-captures tool calls, tool listings,
initialize, identity, and exceptions. Returns a handle whose ``capture()``
records custom events.

Idempotent per server instance — a second call reuses the existing tracking
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 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`.
"""
opts = options or MCPAnalyticsOptions()

# The wrapping path hooks the official MCP SDK's server internals, so it needs the
# `mcp` package. It's a peer dependency (you already have it — you built the server
# with it), imported lazily here rather than bundled. PostHogMCP (custom dispatchers)
# doesn't need it at all. Raise a clear error rather than a silent no-op below.
try:
import mcp # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"instrument() needs the MCP SDK. Install it with: pip install 'mcp>=1.26'. "
"(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:
if opts.logger:
set_logger(opts.logger)

client = _resolve_client(posthog_client)
if client is None:
log("Warning: no PostHog client available; MCP events will not be sent.")

if get_server_tracking_data(key) is not None:
log("instrument() - server already instrumented, skipping initialization")
return McpAnalytics(key)

sink = McpEventSink(client) if client is not None else None
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)
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."
)

return McpAnalytics(key)
except Exception as error: # noqa: BLE001
log(f"Warning: failed to instrument server - {error}")
return _NoopAnalytics()
Loading
Loading