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-keeps-sdk-identity.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/migration-7.x-to-8.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<version>`.
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 |
Expand Down
30 changes: 2 additions & 28 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 3 additions & 4 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +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.
Use a client dedicated to MCP analytics if the application also captures unrelated events.
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.

## Defaults and opt-outs

Expand Down
5 changes: 0 additions & 5 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 0 additions & 15 deletions posthog/mcp/_lib_identity.py

This file was deleted.

1 change: 0 additions & 1 deletion posthog/mcp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@
)

POSTHOG_MCP_ANALYTICS_SOURCE = "posthog_mcp_analytics"
POSTHOG_MCP_LIB_NAME = "posthog-python-mcp"


class PostHogMCPAnalyticsEvent:
Expand Down
2 changes: 0 additions & 2 deletions posthog/mcp/posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
37 changes: 3 additions & 34 deletions posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -316,7 +314,6 @@ def flags(
"/flags/?v=2",
timeout,
session=_get_flags_session(),
_user_agent=user_agent,
**kwargs,
)
return _process_response(
Expand All @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions posthog/test/mcp/test_no_crash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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


Expand Down
78 changes: 2 additions & 76 deletions posthog/test/mcp/test_posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading