From 9c932bea86a2acfb6d00f8cfd45ac2d65ed4eeb1 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 7 Oct 2026 15:24:37 -0700 Subject: [PATCH 1/2] feat!: keep the posthog-python identity when instrumenting MCP MCP instrumentation relabeled the whole client, so the app's own events and flag requests reported posthog-python-mcp. Capture v1 takes $lib from the per-request PostHog-Sdk-Info header, which is now always posthog-python. --- .sampo/changesets/mcp-keeps-sdk-identity.md | 5 ++ docs/migration-7.x-to-8.0.md | 13 ++++ posthog/client.py | 30 +------- posthog/mcp/README.md | 6 +- posthog/mcp/__init__.py | 5 -- posthog/mcp/_lib_identity.py | 15 ---- posthog/mcp/constants.py | 1 - posthog/mcp/posthog_mcp.py | 2 - posthog/test/mcp/test_no_crash.py | 4 +- posthog/test/mcp/test_posthog_mcp.py | 78 +-------------------- posthog/test/test_capture_send.py | 14 ++-- 11 files changed, 34 insertions(+), 139 deletions(-) create mode 100644 .sampo/changesets/mcp-keeps-sdk-identity.md delete mode 100644 posthog/mcp/_lib_identity.py diff --git a/.sampo/changesets/mcp-keeps-sdk-identity.md b/.sampo/changesets/mcp-keeps-sdk-identity.md new file mode 100644 index 00000000..731c7faa --- /dev/null +++ b/.sampo/changesets/mcp-keeps-sdk-identity.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: major +--- + +MCP instrumentation no longer relabels the client as `posthog-python-mcp`. `posthog.mcp.instrument()` and `PostHogMCP` used to change `$lib`, the `PostHog-Sdk-Info` header and the feature flag request `User-Agent` for every event the client sent, including the app's own events. All events now report `posthog-python`. Filter MCP traffic on the `$mcp_*` events and properties. diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index e3b07571..60014558 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -19,6 +19,7 @@ You need to change code if your app does any of these: - sets `$is_server`, `$geoip_disable` or a system property such as `$os` and expects the SDK's value to win - tests the AI integrations with a mock client and asserts on `capture` - passes its own client object to the AI integrations +- sets `$lib` or `$lib_version`, or filters on `$lib = "posthog-python-mcp"` ## Endpoints @@ -30,6 +31,18 @@ You need to change code if your app does any of these: If you send events to a self-hosted PostHog, check that it serves both endpoints before you upgrade. An endpoint that is not served drops every event sent to it. +## SDK identity + +PostHog sets `$lib` and `$lib_version` on every event from the `PostHog-Sdk-Info` request header, which is always `posthog-python/`. +The SDK removes `$lib` and `$lib_version` from the properties it sends. +A value you set in a call, in `super_properties` or in `before_send` does not reach PostHog. + +MCP instrumentation no longer relabels the client. +In 7.x, `posthog.mcp.instrument()` and `PostHogMCP` set the client's identity to `posthog-python-mcp`. +With the default client, or any client the app also used, every event and feature flag request from the app then reported `posthog-python-mcp`, not only the MCP events. +In 8.0, MCP events report `posthog-python` like all other events. +To find MCP traffic, filter on the `$mcp_*` events and properties instead of `$lib`. + ## Removed options and APIs | Removed | Use instead | diff --git a/posthog/client.py b/posthog/client.py index 0f7b415e..c0a8f883 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -101,14 +101,11 @@ from posthog.poller import Poller from posthog.release_id import _resolve_release_id from posthog.request import ( - USER_AGENT as _USER_AGENT, APIError, DatetimeSerializer as _DatetimeSerializer, QuotaLimitError, RequestsConnectionError, RequestsTimeout, - _get as _get_with_identity, - _remote_config as _remote_config_with_identity, determine_server_host, flags, get, @@ -1198,19 +1195,6 @@ def __init__( self._warn_if_duplicate_async_client() - def _set_library_identity(self, library_id: str, library_version: str) -> None: - """Override the SDK identity stamped on events and outbound requests.""" - self._library_id = library_id - self._library_version = library_version - self._sdk_info = f"{library_id}/{library_version}" - for lane in self._lanes: - lane.sdk_info = self._sdk_info - for consumer in lane.consumers: - consumer._sdk_info = self._sdk_info - - def _request_identity_kwargs(self) -> Dict[str, str]: - return {"_user_agent": self._sdk_info} if self._sdk_info != _USER_AGENT else {} - @property def queue(self) -> Queue: """The analytics lane's queue (kept for backwards compatibility).""" @@ -1664,8 +1648,6 @@ def _get_flags_decision( if flag_keys_to_evaluate: request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate - if self._sdk_info != _USER_AGENT: - request_data["_user_agent"] = self._sdk_info resp_data = flags( self.api_key, @@ -3474,14 +3456,12 @@ def _fetch_feature_flags_from_api(self): cache_data_to_store: Optional[FlagDefinitionCacheData] = None old_fingerprint: Optional[str] = None try: - request_get = _get_with_identity if self._request_identity_kwargs() else get - response = request_get( + response = get( personal_api_key, f"/flags/definitions?token={self.api_key}&send_cohorts", self.host, timeout=10, etag=request_etag, - **self._request_identity_kwargs(), ) # Canonical serialization can be expensive; do it before publication. @@ -4465,18 +4445,12 @@ def get_remote_config_payload(self, key: str): return None try: - request_remote_config = ( - _remote_config_with_identity - if self._request_identity_kwargs() - else remote_config - ) - return request_remote_config( + return remote_config( self.personal_api_key, self.api_key, self.host, key, timeout=self.feature_flags_request_timeout_seconds, - **self._request_identity_kwargs(), ) except Exception as e: self.log.exception( diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 9db9bc31..33c6b006 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -31,9 +31,9 @@ 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. -MCP analytics events report `$lib: "posthog-python-mcp"` and the installed `posthog` package version in `$lib_version`. -Request headers use the same identity and package version, so SDK Health can compare MCP traffic with Python SDK releases. -Because `$lib` is a client-level identity, `instrument()` relabels every event sent by the client passed to it. +MCP analytics events report `$lib: "posthog-python"` and the installed `posthog` package version in `$lib_version`, like every other event the client sends. +PostHog sets both from the client's `PostHog-Sdk-Info` request header, so `instrument()` and `PostHogMCP` do not relabel the client. +Filter MCP traffic by its `$mcp_*` events and properties instead. Use a client dedicated to MCP analytics if the application also captures unrelated events. ## Defaults and opt-outs diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 70d37eb4..015c9ee5 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -351,11 +351,6 @@ def instrument( "on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server." ) - if client is not None: - from ._lib_identity import apply_mcp_lib_identity - - apply_mcp_lib_identity(client) - # 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. diff --git a/posthog/mcp/_lib_identity.py b/posthog/mcp/_lib_identity.py deleted file mode 100644 index 02bb11e5..00000000 --- a/posthog/mcp/_lib_identity.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Apply the MCP-specific identity to the underlying PostHog client.""" - -from __future__ import annotations - -from ..client import Client -from ..version import VERSION - -from .constants import POSTHOG_MCP_LIB_NAME - - -def apply_mcp_lib_identity(client: Client) -> None: - """Relabel every event sent by ``client`` as coming from ``posthog.mcp``.""" - set_identity = getattr(client, "_set_library_identity", None) - if set_identity is not None: - set_identity(POSTHOG_MCP_LIB_NAME, VERSION) diff --git a/posthog/mcp/constants.py b/posthog/mcp/constants.py index ee8cf96a..122b9607 100644 --- a/posthog/mcp/constants.py +++ b/posthog/mcp/constants.py @@ -43,7 +43,6 @@ ) POSTHOG_MCP_ANALYTICS_SOURCE = "posthog_mcp_analytics" -POSTHOG_MCP_LIB_NAME = "posthog-python-mcp" class PostHogMCPAnalyticsEvent: diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index 49f89262..b91a29ee 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -42,7 +42,6 @@ fire_and_forget, virtual_tool_collision_message, ) -from ._lib_identity import apply_mcp_lib_identity from .logger import log, warn from ._model_parameters import ( add_model_parameter_to_schema, @@ -122,7 +121,6 @@ def __init__( ) -> None: self._server_build = validate_server_build(server_build) super().__init__(api_key, **kwargs) - apply_mcp_lib_identity(self) self._mcp_sink = McpEventSink(self) self._missing_capability_tool_name = ( missing_capability_tool_name or _GET_MORE_TOOLS_NAME diff --git a/posthog/test/mcp/test_no_crash.py b/posthog/test/mcp/test_no_crash.py index ffe0f4a3..72713c84 100644 --- a/posthog/test/mcp/test_no_crash.py +++ b/posthog/test/mcp/test_no_crash.py @@ -61,7 +61,7 @@ def test_low_level_server_detected_on_installed_major(): assert compat.is_low_level_server(object()) is False -def test_instrument_relabels_the_host_client(): +def test_instrument_keeps_the_host_client_identity(): from mcp.server.lowlevel import Server captured = [] @@ -74,7 +74,7 @@ def before_send(event): instrument(Server("probe-lib-identity"), client) client.capture("after instrumentation") - assert captured[0]["properties"]["$lib"] == "posthog-python-mcp" + assert captured[0]["properties"]["$lib"] == "posthog-python" assert captured[0]["properties"]["$lib_version"] == VERSION diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 8820a156..8f1df2d4 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -109,86 +109,12 @@ def before_send(event): assert captured[2]["properties"]["$mcp_server_build"] == "default-build" -async def test_mcp_events_use_mcp_library_identity(): - captured = [] - - def before_send(event): - captured.append(event) - return event - - client = PostHogMCP( - "phc_test", - host="https://us.i.posthog.com", - send=False, - before_send=before_send, - ) - client.capture_tool_call("broken", is_error=True, error=RuntimeError("kaboom")) - await _flush() - - assert {event["event"] for event in captured} == {"$mcp_tool_call", "$exception"} - assert all( - event["properties"]["$lib"] == "posthog-python-mcp" - and event["properties"]["$lib_version"] == VERSION - for event in captured - ) - - -def test_mcp_library_identity_reaches_capture_v1_header(): +def test_mcp_client_keeps_the_sdk_identity(): client = PostHogMCP("phc_test", sync_mode=True) with mock.patch("posthog.client._send_v1_batch") as send: client.capture("$mcp_custom") - assert send.call_args.kwargs["sdk_info"] == f"posthog-python-mcp/{VERSION}" - event = send.call_args.args[2][0] - assert event["properties"]["$lib"] == "posthog-python-mcp" - assert event["properties"]["$lib_version"] == VERSION - - -def test_mcp_library_identity_reaches_feature_flag_requests(): - response = mock.Mock(status_code=200) - response.json.return_value = {"flags": {}} - client = PostHogMCP("phc_test", send=False) - - with mock.patch( - "posthog.request._flags_session.post", return_value=response - ) as post: - client.evaluate_flags("user_1") - - assert post.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - - -def test_mcp_library_identity_reaches_feature_flag_definition_requests(): - response = mock.Mock(status_code=200, headers={}) - response.json.return_value = {"flags": [], "group_type_mapping": {}, "cohorts": {}} - client = PostHogMCP( - "phc_test", - secret_key="phs_test", - send=False, - enable_local_evaluation=False, - ) - - with mock.patch("posthog.request._session.get", return_value=response) as get: - client.load_feature_flags() - - assert get.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) - client.shutdown() - - -def test_mcp_library_identity_reaches_remote_config_requests(): - response = mock.Mock(status_code=200, headers={}) - response.json.return_value = "payload" - client = PostHogMCP("phc_test", secret_key="phs_test", send=False) - - with mock.patch("posthog.request._session.get", return_value=response) as get: - assert client.get_remote_config_payload("flag-key") == "payload" - - assert get.call_args.kwargs["headers"]["User-Agent"] == ( - f"posthog-python-mcp/{VERSION}" - ) + assert send.call_args.kwargs["sdk_info"] == f"posthog-python/{VERSION}" async def test_capture_initialize_and_tools_list(): diff --git a/posthog/test/test_capture_send.py b/posthog/test/test_capture_send.py index 1f8c9506..e31abd17 100644 --- a/posthog/test/test_capture_send.py +++ b/posthog/test/test_capture_send.py @@ -162,11 +162,11 @@ def test_required_headers_present(self) -> None: self.assertEqual(request_timestamp.utcoffset(), timedelta(0)) def test_custom_sdk_info_headers(self) -> None: - headers = self._post( - _results_response({}), sdk_info="posthog-python-mcp/0.3.0" - )["headers"] - self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python-mcp/0.3.0") - self.assertEqual(headers["User-Agent"], "posthog-python-mcp/0.3.0") + headers = self._post(_results_response({}), sdk_info="posthog-python/9.9.9")[ + "headers" + ] + self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python/9.9.9") + self.assertEqual(headers["User-Agent"], "posthog-python/9.9.9") def test_no_api_key_in_body(self) -> None: # v1 authenticates via the Bearer header; the key must not leak into the body. @@ -288,9 +288,9 @@ def test_custom_sdk_info_is_forwarded(self) -> None: stub = self._run( [_msg("u-1")], [_results_response({"u-1": "ok"})], - sdk_info="posthog-python-mcp/0.3.0", + sdk_info="posthog-python/9.9.9", ) - self.assertEqual(stub.calls[0]["sdk_info"], "posthog-python-mcp/0.3.0") + self.assertEqual(stub.calls[0]["sdk_info"], "posthog-python/9.9.9") def test_absent_uuid_treated_as_accepted(self) -> None: # Empty results map: the event is neither retried nor errored. From 307bb9e3db063efb57c35d33453892f64f3be77a Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 7 Oct 2026 18:18:35 -0700 Subject: [PATCH 2/2] chore: remove the unused request identity plumbing and stale MCP client advice --- posthog/mcp/README.md | 1 - posthog/request.py | 37 +++---------------------------------- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 33c6b006..0e8cfe1a 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -34,7 +34,6 @@ but anyone wrapping a server already has it. MCP analytics events report `$lib: "posthog-python"` and the installed `posthog` package version in `$lib_version`, like every other event the client sends. PostHog sets both from the client's `PostHog-Sdk-Info` request header, so `instrument()` and `PostHogMCP` do not relabel the client. Filter MCP traffic by its `$mcp_*` events and properties instead. -Use a client dedicated to MCP analytics if the application also captures unrelated events. ## Defaults and opt-outs diff --git a/posthog/request.py b/posthog/request.py index 2f68fc38..26b965af 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -219,7 +219,6 @@ def post( ) -> requests.Response: """Post the `kwargs` to the API""" log = logging.getLogger("posthog") - user_agent = kwargs.pop("_user_agent", USER_AGENT) body = kwargs body["sent_at"] = datetime.now(tz=timezone.utc).isoformat() trimmed_host = remove_trailing_slash(normalize_host(host)) @@ -232,7 +231,7 @@ def post( json.dumps({**body, "api_key": "[redacted]"}, cls=DatetimeSerializer), url, ) - headers = {"Content-Type": "application/json", "User-Agent": user_agent} + headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} res = (session or _get_session()).post( url, data=data, headers=headers, timeout=timeout @@ -304,7 +303,6 @@ def flags( **kwargs, ) -> Any: """Post the kwargs to the flags API endpoint with bounded transient retries.""" - user_agent = kwargs.pop("_user_agent", USER_AGENT) retries = max(0, max_retries) failed_attempt = 0 @@ -316,7 +314,6 @@ def flags( "/flags/?v=2", timeout, session=_get_flags_session(), - _user_agent=user_agent, **kwargs, ) return _process_response( @@ -343,24 +340,11 @@ def remote_config( timeout: int = 15, ) -> Any: """Get remote config flag value from remote_config API endpoint""" - return _remote_config(personal_api_key, project_api_key, host, key, timeout) - - -def _remote_config( - personal_api_key: str, - project_api_key: str, - host: Optional[str] = None, - key: str = "", - timeout: int = 15, - *, - _user_agent: str = USER_AGENT, -) -> Any: - response = _get( + response = get( personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}", host, timeout, - _user_agent=_user_agent, ) return response.data @@ -379,25 +363,10 @@ def get( - not_modified=True and data=None if server returns 304 - not_modified=False and data=response if server returns 200 """ - return _get(api_key, url, host, timeout, etag) - - -def _get( - api_key: str, - url: str, - host: Optional[str] = None, - timeout: Optional[int] = None, - etag: Optional[str] = None, - *, - _user_agent: str = USER_AGENT, -) -> GetResponse: log = logging.getLogger("posthog") trimmed_host = remove_trailing_slash(normalize_host(host)) full_url = trimmed_host + url - headers = { - "Authorization": "Bearer %s" % api_key, - "User-Agent": _user_agent, - } + headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} if etag: headers["If-None-Match"] = etag