feat(mcp): PostHog MCP analytics SDK for Python (posthog.mcp)#691
feat(mcp): PostHog MCP analytics SDK for Python (posthog.mcp)#691lucasheriques wants to merge 9 commits into
Conversation
Port the core of @posthog/mcp to Python as a posthog.mcp submodule: event vocabulary, inline uuidv7 + FNV-1a ids, STDIO-safe logger, sanitization, layered truncation, $mcp_* event building, $exception fan-out (reusing posthog.exception_utils), and the sanitize->truncate->before_send->capture sink. Adds the `mcp` optional extra. No server wrapping yet (M2). Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
…e (M2) instrument(server, posthog_client) now wraps a FastMCP server by hooking two central seams: ToolManager.call_tool (strip injected context before Pydantic validation, time the call, capture $mcp_tool_call + $exception, re-raise) and the ListToolsRequest handler ($mcp_tools_list + context-parameter injection). $mcp_initialize is emitted lazily per session from client_params. Adds the import guard, per-server state in a WeakKeyDictionary, session resolution with an asyncio lock, identity dedup, and the custom-event handle. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
A runnable FastMCP server instrumented with posthog.mcp that emits the full $mcp_* event set. Verified end-to-end against project 2: tool calls, intent, error capture, initialize, identify, and a custom event all ingest correctly. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
instrument() now also wraps a low-level mcp.server.Server by hooking its public request_handlers (CallToolRequest, ListToolsRequest). Errors are read from the isError CallToolResult the low-level handler produces. context is injected as an *optional* schema property there (the advertised schema is also the validation schema) so a call omitting it is never rejected. PostHogMCP is a posthog Client subclass for custom dispatchers with capture_tool_call / capture_initialize / capture_tools_list / capture_missing_capability + prepare_tool_list / prepare_tool_call, flowing through the same pipeline. Shared tool-list helpers moved into instrumentation. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
reportMissing advertises the get_more_tools virtual tool in tools/list; calling it emits $mcp_missing_capability (not $mcp_tool_call) and returns the canned acknowledgement, across FastMCP, low-level Server, and PostHogMCP. enableConversationId injects an optional conversation_id parameter, mints one when the agent omits it, captures it as $mcp_conversation_id, and appends a prompt-back asking the agent to echo it on later calls. Parity note: the TS instrument() does not emit resources/prompts events, so those are intentionally omitted here too. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
|
Reviews (1): Last reviewed commit: "chore(mcp): add changeset for the Python..." | Re-trigger Greptile |
| async def resolve_tool_call_intent( | ||
| data: MCPAnalyticsData, | ||
| request: Dict[str, Any], | ||
| extra: Optional[Dict[str, Any]] = None, | ||
| ) -> Optional[ResolvedIntent]: | ||
| context_argument = _get_context_argument(request) | ||
| name = (request.get("params") or {}).get("name") | ||
| if ( | ||
| is_context_enabled(data.options.context) | ||
| and name != "get_more_tools" | ||
| and context_argument | ||
| ): | ||
| return (context_argument, "context_parameter") | ||
| return await _run_intent_fallback(data, request, extra) |
There was a problem hiding this comment.
The
get_more_tools check is hardcoded as a string literal instead of going through resolve_missing_capability_tool_name. When a user configures MCPAnalyticsOptions(missing_capability_tool_name="find_more_tools"), calls to find_more_tools with a context argument will be incorrectly classified as context_parameter intent rather than being skipped as a missing-capability probe — producing wrong analytics data for a documented configuration option.
| async def resolve_tool_call_intent( | |
| data: MCPAnalyticsData, | |
| request: Dict[str, Any], | |
| extra: Optional[Dict[str, Any]] = None, | |
| ) -> Optional[ResolvedIntent]: | |
| context_argument = _get_context_argument(request) | |
| name = (request.get("params") or {}).get("name") | |
| if ( | |
| is_context_enabled(data.options.context) | |
| and name != "get_more_tools" | |
| and context_argument | |
| ): | |
| return (context_argument, "context_parameter") | |
| return await _run_intent_fallback(data, request, extra) | |
| async def resolve_tool_call_intent( | |
| data: MCPAnalyticsData, | |
| request: Dict[str, Any], | |
| extra: Optional[Dict[str, Any]] = None, | |
| ) -> Optional[ResolvedIntent]: | |
| from .tools import resolve_missing_capability_tool_name | |
| context_argument = _get_context_argument(request) | |
| name = (request.get("params") or {}).get("name") | |
| missing_name = resolve_missing_capability_tool_name(data.options) | |
| if ( | |
| is_context_enabled(data.options.context) | |
| and name != missing_name | |
| and context_argument | |
| ): | |
| return (context_argument, "context_parameter") | |
| return await _run_intent_fallback(data, request, extra) |
| def capture( | ||
| self, | ||
| event, | ||
| distinct_id=None, | ||
| properties=None, | ||
| timestamp=None, | ||
| uuid=None, | ||
| **kwargs, | ||
| ): | ||
| self.events.append( | ||
| {"event": event, "distinct_id": distinct_id, "properties": properties or {}} | ||
| ) | ||
| return None | ||
|
|
||
|
|
||
| def make_server(): | ||
| server = FastMCP("test-server") | ||
|
|
||
| @server.tool() | ||
| def add(a: int, b: int) -> int: | ||
| return a + b | ||
|
|
||
| @server.tool() | ||
| def boom() -> str: | ||
| raise ValueError("explode") | ||
|
|
||
| return server | ||
|
|
||
|
|
||
| async def _flush(): | ||
| """Let fire-and-forget capture tasks run to completion.""" | ||
| import posthog.mcp.instrumentation as instr | ||
|
|
||
| for _ in range(10): | ||
| await asyncio.sleep(0) | ||
| pending = [t for t in list(instr._BACKGROUND_TASKS) if not t.done()] | ||
| if pending: | ||
| await asyncio.gather(*pending, return_exceptions=True) | ||
| await asyncio.sleep(0) | ||
|
|
||
|
|
||
| def _events(client, name): | ||
| return [e for e in client.events if e["event"] == name] | ||
|
|
||
|
|
There was a problem hiding this comment.
Duplicated test helpers across all MCP test files
FakeClient, _flush, and _events are defined identically in test_fastmcp.py, test_lowlevel.py, test_features_m4.py, and test_posthog_mcp.py. This is a direct violation of the OnceAndOnlyOnce simplicity rule. Moving them to a shared posthog/test/mcp/conftest.py (as pytest fixtures) would eliminate the repetition and make future changes to the fake client or flush logic apply everywhere automatically.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
instrument() now also accepts a fastmcp.FastMCP (jlowin's standalone FastMCP 2.0), distinct from the official SDK's mcp.server.fastmcp.FastMCP. Its _mcp_server is a subclass of the official low-level Server, so it routes through the low-level adapter via its request_handlers seam — but FastMCP 2.0 validates tool args against the function signature and rejects unexpected kwargs, so the injected context/conversation_id are stripped before dispatch (the low-level adapter is parameterized: strip + required-advisory for fastmcp 2.0, optional + no-strip for a raw Server). Adds fastmcp to the test extra; tests skip if absent. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
|
Added support for jlowin's standalone FastMCP 2.0 (the separate Implementation: jlowin's |
posthog-python Compliance ReportDate: 2026-06-22 20:15:19 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
The new posthog.mcp submodule adds public API surface; refresh the griffe snapshot so the "Public API snapshot" CI check passes. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
|
Reviews (2): Last reviewed commit: "chore(mcp): regenerate public API snapsh..." | Re-trigger Greptile |
…ersation_id, loop leak - Isolate analytics from the tool path: record_tool_call / record_tools_list / record_missing_capability swallow+log internally so a capture failure can never change what the tool returns or raises. - Apply event_properties to $mcp_tools_list / $mcp_initialize / $mcp_missing_capability (previously only $mcp_tool_call), matching the TS fan-out. - conversation_id: only stamp $mcp_conversation_id when the prompt-back was actually delivered (inject first, then capture; clear on error / non-injectable results) so no orphan ids; strip conversation_id from $mcp_parameters; handle FastMCP's (content, structured) tuple result so the prompt-back lands. - fire_and_forget: reuse one daemon background loop for sync hosts instead of leaking a new event loop per call; log background-task exceptions; add drain_pending() + McpAnalytics.flush() to await in-flight events before shutdown (demo uses it). - Bound initialized_sessions (FIFO, cap 1000) to stop a per-session memory leak. - $mcp_tools_list now carries $mcp_is_error=False; fix stale "see M3" docstring. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
What
Adds
posthog.mcp— a Python SDK for PostHog MCP analytics, the Python sibling of the TypeScript@posthog/mcppackage. This is the #1 "what to prioritize next" item on the MCP analytics mega-issue, PostHog/posthog#64016: MCP analytics was TS-only, but many MCP servers are Python.Install:
pip install posthog[mcp].How it works
Packaged as a submodule of
posthog(mirroringposthog.ai), guarded by an optionalmcpextra soimport posthognever requiresmcp. The wire format is byte-identical to@posthog/mcp'sconstants.ts, so the existing MCP analytics dashboard ingests Python-server data with zero backend changes.instrument(server, posthog_client)supports every common Python MCP server:FastMCPand the low-levelServerfrom the officialmodelcontextprotocol/python-sdk(themcppackage)fastmcppackage)PostHogMCP(aClientsubclass) for custom/edge dispatchers with no server objectCaptures
$mcp_tool_call,$mcp_tools_list,$mcp_initialize(lazy),$identify,$exception, and$mcp_missing_capability. Features: agent-intent capture via an injectedcontextarg,identify,before_send,event_properties,report_missing(get_more_tools), andconversation_id.Implementation notes:
ToolManager.call_tool+ theListToolsRequesthandler) rather than per-tool wrapping — late-registered tools are covered automatically._mcp_serversubclasses the officialServer); it validates against the function signature and rejects unexpected kwargs, so the injectedcontext/conversation_idare stripped before dispatch.$exception_listreusesposthog.exception_utils.$mcp_initializeis emitted lazily fromclient_paramsbecause the PythonmcpSDK handlesinitializein the session layer, not viarequest_handlers.Server,context/conversation_idare injected as optional schema properties (that schema is also the validation schema) so a call omitting them is never rejected.Testing
posthog/test/mcp/), driving the realmcp/fastmcpSDK seams with a fake capture client.ruff+mypyclean.examples/mcp_analytics_demo.py) sent the full$mcp_*event set; verified the events ingest with correct intent, error flag, and$mcp_sourceand are readable by the dashboard.Links
Status
Alpha (
minorchangeset). Parity note: the TSinstrument()does not emit resources/prompts events, so those are intentionally omitted here too.Created with PostHog Code