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 13 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) |
There was a problem hiding this comment.
fixed in 31c854a. it goes through resolve_missing_capability_tool_name() now, so a custom missing_capability_tool_name won't have its calls misread as context intent.
| 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!
There was a problem hiding this comment.
done in 31c854a. pulled them into _helpers.py. i went with a shared module instead of fixtures since they get called with args inside make_server() rather than injected.
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-23 21:03:17 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
| # 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 |
There was a problem hiding this comment.
you need to change https://github.com/PostHog/posthog-python/blob/main/LICENSE as well
There was a problem hiding this comment.
done in 31c854a. added the MCPCat block to LICENSE so it matches the file headers. thanks for catching it!
|
just an initial agent review before proper human review: Blocking / high confidence findings
Medium concerns
|
|
some comparison with the mcp node based impl. 1. tools/list capture is weaker than TypeScriptTypeScript captures:
Python currently records mostly:
No response, no duration, no failure capture if original(req) raises. This is probably the biggest dashboard parity gap. 2. $mcp_initialize is not equivalentTypeScript wraps the actual initialize handler and captures request, response, and timing. Python emits initialize lazily from prepare_request(), but prepare_request() only runs on tool
Some limitation may be unavoidable in Python MCP SDK, but Python should at least emit initialize 3. Injected arg stripping is inconsistentTypeScript strips injected context/conversation_id before the tool sees them. Python FastMCP strips both only when _tool_owns_context(server, name) is false: if isinstance(arguments, dict) and not _tool_owns_context(server, name):
call_arguments = {k: v for k, v in arguments.items() if k not in _INJECTED_KEYS}That means a tool with a real context arg can also receive injected conversation_id, which is For raw low-level Python, injected args are intentionally left in place. That may be necessary 4. Low-level thrown exceptions are not captured like TSTypeScript wraps execution with try/catch and captures failed tool events before rethrowing. Python low-level assumes the MCP handler converts failures into CallToolResult(isError=True). If Worth wrapping original(req) in try/except anyway. 5. Idempotency/tracking key differsTypeScript canonicalizes high-level servers to the underlying low-level server before storing Python stores tracking against whatever object was passed to instrument(). For wrappers exposing Not common, but TS handles this more robustly. 6. flush() still misses sync background futuresPython added McpAnalytics.flush(), but drain_pending() only awaits asyncio.Task, not the 7. PostHogMCP unnecessarily requires mcpJS PostHogMCP is useful for custom dispatchers. Python exports PostHogMCP, but importing |
…NSE, test dedup - intent: resolve the missing-capability probe through resolve_missing_capability_tool_name() instead of the hardcoded "get_more_tools", so a custom missing_capability_tool_name no longer misclassifies its calls as context_parameter intent. - version: fix the comment — __version__ IS stamped as sdk_version on every captured event (capture.py), it is not informational-only. - LICENSE: add the MCPCat MIT attribution block (code derived from MCPCat/mcpcat-typescript-sdk), mirroring the per-file headers and the posthog-js LICENSE. - tests: extract the duplicated FakeClient / flush / events helpers into posthog/test/mcp/_helpers.py (OnceAndOnlyOnce) and import them. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
…d strip From Manoel's PR review: - flush: drain_pending() now also awaits the concurrent.futures.Future used on the sync background-loop path (PostHogMCP / sync hosts), and PostHogMCP.flush() /shutdown() drain those futures before tearing the client down — previously only asyncio.Task was awaited, so trailing sync captures could be dropped. - conversation_id: strip each injected key independently in the FastMCP adapter. A tool that declares its own `context` keeps it while an SDK-injected `conversation_id` is still stripped (the two were coupled to context-ownership). - tools/list parity: capture $mcp_response and $mcp_duration_ms, and wrap the list handler so a raised error is recorded as an errored $mcp_tools_list + $exception (was: names only, no failure capture). - $mcp_initialize now also emits on the first tools/list, so a client that lists tools but never calls one is still counted (deduped per session). - low-level call handler wraps original(req) in try/except: a handler wired straight into request_handlers (bypassing the decorator that converts raises to isError) is captured instead of silently dropped. - pin mcp<2 since the adapters hook private SDK seams. Tests in test_review_fixes.py cover each. Deferred (replied on the PR): moving the import guard so PostHogMCP works without `mcp` installed, and canonicalizing the idempotency tracking key (mostly covered by the per-seam wrapped guards). Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
Address the two findings deferred from the previous review pass: - PostHogMCP (custom dispatchers) no longer requires `pip install posthog[mcp]`. The official MCP SDK import and the wrapping-path adapters are deferred into instrument() instead of guarding the whole module, so `from posthog.mcp import PostHogMCP` works with no SDK installed. instrument() raises a clear install hint when called without it. - instrument() keys tracking state on the underlying low-level server (via _canonical_server), so instrumenting a high-level FastMCP and its ._mcp_server resolve to one state instead of two divergent ones — matching the TS SDK. Tests: PostHogMCP imports under a blocked `mcp` (subprocess), and instrumenting wrapper + underlying server is idempotent on shared state. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
… peer dep The `mcp` extra was the wrong model: instrument() callers definitionally already have the MCP SDK (you can't build the server you pass it without `mcp`/`fastmcp`), so an extra that installs it "provides" something you already have. Mirror the TS package, where @modelcontextprotocol/sdk is a peerDependency. - Remove the `posthog[mcp]` optional extra. Single install path: `pip install posthog`. - instrument() imports the SDK lazily and now version-checks it at runtime (advises when outside the tested mcp>=1.26,<2 range) — replaces the pin the extra used to carry, and catches the real case the pin couldn't (a user who already has an old mcp). - Update the missing-SDK error to `pip install 'mcp>=1.26'` and refresh the docstring + changeset. PostHogMCP still needs nothing beyond posthog. Nothing published yet, so dropping the extra has no back-compat cost. Generated-By: PostHog Code Task-Id: b21bc954-5de3-4512-a0d5-6bec2371f782
|
thanks manoel, this was a really useful pass. fixed all of it across 0d998ab, 37280c2, and f590c8c. flush() missing sync futures. fixed. drain_pending() waits on the concurrent.futures.Future from the sync path now too, and PostHogMCP.flush()/shutdown() drain before tearing down. without it a trailing event could still be in flight on sync hosts. conversation_id leaking into context-owning tools. fixed. the two injected keys get stripped independently now, so a tool that declares its own context keeps it while conversation_id still gets stripped. added tests for context-only, conversation_id-only, and both. (the official FastMCP ignores extra kwargs so nothing was crashing, but it was still wrong, and it does bite the stricter fastmcp 2.0 path.) tools/list weaker than TS. fixed. it captures $mcp_response and $mcp_duration_ms now, and records a failed $mcp_tools_list plus $exception if the list handler raises. agreed this was the biggest gap. $mcp_initialize not on list-only sessions. fixed. it emits on the first tools/list too, deduped per session. the deeper difference stays: the Python SDK handles initialize in the session layer rather than request_handlers, so there's no real initialize request, response, or timing to capture. left a note in the adapter. low-level raises not captured. fixed. wrapped original(req) so a handler that raises directly gets captured before re-raising, same as the TS path. PostHogMCP requiring mcp. fixed. the SDK import and the wrapping adapters are deferred into instrument() now, so idempotency tracking key. fixed. tracking keys on the underlying low-level server now, so instrumenting a high-level FastMCP and its dependency range. rather than pin via an extra, we now treat the MCP SDK as a peer dependency (you already have it if you built the server) and check the installed version at runtime, warning outside the tested >=1.26,<2 range. dropped the posthog[mcp] extra entirely, so install is just still draft until the pypi publish, but ready for a proper read whenever you have time. |
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