-
Notifications
You must be signed in to change notification settings - Fork 69
feat(mcp): PostHog MCP analytics SDK for Python (posthog.mcp) #691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
lucasheriques
wants to merge
13
commits into
main
Choose a base branch
from
posthog-code/mcp-analytics-python-sdk
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
5c15a90
feat(mcp): add MCP analytics SDK core pipeline (M1)
lucasheriques f6b18fa
feat(mcp): FastMCP instrument() with intent, identity, lazy initializ…
lucasheriques a27efb8
docs(mcp): add dogfood demo example for the MCP analytics SDK
lucasheriques 00ee639
feat(mcp): low-level Server adapter + PostHogMCP custom-dispatcher (M3)
lucasheriques 5370112
feat(mcp): get_more_tools missing-capability + conversation_id (M4)
lucasheriques bee082c
chore(mcp): add changeset for the Python MCP analytics SDK
lucasheriques 5e1d63b
feat(mcp): support jlowin's standalone FastMCP 2.0 (fastmcp package)
lucasheriques 6c01663
chore(mcp): regenerate public API snapshot for posthog.mcp
lucasheriques d39928c
fix(mcp): address review findings — isolation, event_properties, conv…
lucasheriques 31c854a
fix(mcp): address PR review — intent tool name, version comment, LICE…
lucasheriques 0d998ab
fix(mcp): address review — TS parity gaps, sync flush, conversation_i…
lucasheriques 37280c2
fix(mcp): PostHogMCP works without the mcp SDK + canonical tracking key
lucasheriques f590c8c
refactor(mcp): unify install to `pip install posthog`, treat mcp as a…
lucasheriques File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| """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() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you need to change https://github.com/PostHog/posthog-python/blob/main/LICENSE as well
There was a problem hiding this comment.
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!