From 16f4a553a799248adba3d2291f51fb4e42d96f0e Mon Sep 17 00:00:00 2001 From: Georgis Andonis <6430745+gesh@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:43:39 +0300 Subject: [PATCH 1/3] feat(mcp): capture server build metadata Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe --- .sampo/changesets/mcp-server-build.md | 5 +++++ posthog/mcp/README.md | 16 ++++++++++++++++ posthog/mcp/__init__.py | 2 ++ posthog/mcp/_capture.py | 1 + posthog/mcp/_posthog_events.py | 4 ++++ posthog/mcp/_server_build.py | 20 ++++++++++++++++++++ posthog/mcp/constants.py | 1 + posthog/mcp/posthog_mcp.py | 4 ++++ posthog/mcp/types.py | 6 ++++-- posthog/test/mcp/test_features_m4.py | 21 +++++++++++++++++++++ posthog/test/mcp/test_posthog_mcp.py | 12 +++++++++++- references/public_api_snapshot.txt | 6 ++++-- 12 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .sampo/changesets/mcp-server-build.md create mode 100644 posthog/mcp/_server_build.py diff --git a/.sampo/changesets/mcp-server-build.md b/.sampo/changesets/mcp-server-build.md new file mode 100644 index 000000000..f6c220875 --- /dev/null +++ b/.sampo/changesets/mcp-server-build.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add optional MCP server build metadata. Set `server_build` to record an immutable deployment identifier as `$mcp_server_build` on MCP events from automatic instrumentation and custom dispatchers. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 287484dc8..ad43ea57f 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -50,6 +50,22 @@ from posthog.mcp import MCPAnalyticsOptions, instrument instrument(server, posthog, MCPAnalyticsOptions(capture_model=False, enable_conversation_id=False)) ``` +Set `server_build` to connect each MCP event to the exact deployed code. Use an +immutable value such as a Git commit SHA or a container image digest. + +```python +import os + +instrument( + server, + posthog, + MCPAnalyticsOptions(server_build=os.environ.get("GIT_SHA")), +) +``` + +The value must contain 1 to 256 characters. The SDK records it as +`$mcp_server_build` on all MCP events. + Model capture adds an `llm_model` argument to compatible tool schemas, required on the official high-level adapters and optional elsewhere. Dispatch never enforces it, so servers keep working; strict-schema clients see the new field. Set `capture_model=False` to leave schemas untouched. diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 298de241c..b42cde8bb 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -38,6 +38,7 @@ from posthog.client import Client from ._capture import capture_event +from ._server_build import validate_server_build from .constants import ( POSTHOG_MCP_ANALYTICS_SOURCE, PostHogMCPAnalyticsEvent, @@ -248,6 +249,7 @@ def instrument( :param options: Optional :class:`MCPAnalyticsOptions`. """ opts = options or MCPAnalyticsOptions() + validate_server_build(opts.server_build) # Install the logger first so the version advisory below (and any warning) is # actually visible rather than going to the default no-op sink. diff --git a/posthog/mcp/_capture.py b/posthog/mcp/_capture.py index 424501527..bf97ab1e9 100644 --- a/posthog/mcp/_capture.py +++ b/posthog/mcp/_capture.py @@ -45,6 +45,7 @@ def capture_event( "duration": duration, "sdk_language": "Python", "sdk_version": __version__, + "server_build": data.options.server_build, "server_name": data.server_name, "server_version": data.server_version, "client_name": event_input.get("client_name"), diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index 2ea292093..d4433b245 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -126,6 +126,8 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None: properties[_P.DURATION_MS] = event["duration"] if event.get("server_name"): properties[_P.SERVER_NAME] = event["server_name"] + if event.get("server_build"): + properties[_P.SERVER_BUILD] = event["server_build"] if event.get("server_version"): properties[_P.SERVER_VERSION] = event["server_version"] if event.get("client_name"): @@ -275,6 +277,8 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties[_P.TOOL_CATEGORY] = event["tool_category"] if event.get("server_name"): properties[_P.SERVER_NAME] = event["server_name"] + if event.get("server_build"): + properties[_P.SERVER_BUILD] = event["server_build"] if event.get("server_version"): properties[_P.SERVER_VERSION] = event["server_version"] if event.get("client_name"): diff --git a/posthog/mcp/_server_build.py b/posthog/mcp/_server_build.py new file mode 100644 index 000000000..0c395af81 --- /dev/null +++ b/posthog/mcp/_server_build.py @@ -0,0 +1,20 @@ +"""Validate server build identifiers for MCP analytics events.""" + +from __future__ import annotations + +from typing import Any, Optional + +MAX_SERVER_BUILD_LENGTH = 256 + + +def validate_server_build(server_build: Any) -> Optional[str]: + """Reject build identifiers that the event pipeline cannot record exactly.""" + if server_build is None: + return None + if not isinstance(server_build, str) or not server_build: + raise TypeError("server_build must be a non-empty string.") + if len(server_build) > MAX_SERVER_BUILD_LENGTH: + raise ValueError( + f"server_build must not exceed {MAX_SERVER_BUILD_LENGTH} characters." + ) + return server_build diff --git a/posthog/mcp/constants.py b/posthog/mcp/constants.py index 85f2fed63..a4c9e7a5b 100644 --- a/posthog/mcp/constants.py +++ b/posthog/mcp/constants.py @@ -94,6 +94,7 @@ class PostHogMCPAnalyticsProperty: RESOURCE_NAME = "$mcp_resource_name" RESPONSE = "$mcp_response" SERVER_NAME = "$mcp_server_name" + SERVER_BUILD = "$mcp_server_build" SERVER_VERSION = "$mcp_server_version" SESSION_ID = "$session_id" SOURCE = "$mcp_source" diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index c0fb8190c..7d6c31ca2 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -43,6 +43,7 @@ resolve_model, ) from ._sink import McpCaptureOptions, McpEventSink +from ._server_build import validate_server_build from .feedback import ( build_feedback_event_properties, build_feedback_intent, @@ -80,8 +81,10 @@ def __init__( mcp_exception_autocapture: bool = True, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, + server_build: Optional[str] = None, **kwargs: Any, ) -> None: + self._server_build = validate_server_build(server_build) super().__init__(api_key, **kwargs) apply_mcp_lib_identity(self) self._mcp_sink = McpEventSink(self) @@ -549,6 +552,7 @@ def _base_event( # off the request automatically. "client_user_agent": client_user_agent, "vendor_client": vendor_client, + "server_build": self._server_build, } if distinct_id: event["identify_actor_given_id"] = distinct_id diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index 6b8bfc444..a80593d2f 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -209,9 +209,11 @@ class MCPAnalyticsOptions: # defaults; the object form renames the tool, replaces its description, # declares host-specific extra_properties, or wires an on_feedback handler. # Covers what `report_missing` covers (as feedback_type "missing_capability"), - # so new integrations should enable only one of the two. New field appended - # last: positional construction of the earlier fields must keep working. + # so new integrations should enable only one of the two. collect_feedback: Union[bool, CollectFeedbackOptions] = False + # Exact deployment identifier recorded as `$mcp_server_build`. Use an + # immutable value such as a Git commit SHA or a container image digest. + server_build: Optional[str] = None @dataclass diff --git a/posthog/test/mcp/test_features_m4.py b/posthog/test/mcp/test_features_m4.py index 6f0b4ce9c..bafb4ac0c 100644 --- a/posthog/test/mcp/test_features_m4.py +++ b/posthog/test/mcp/test_features_m4.py @@ -124,6 +124,27 @@ async def test_fastmcp_conversation_id_captured(): assert calls and calls[0]["properties"].get("$mcp_conversation_id") # minted +async def test_fastmcp_captures_server_build_on_all_events(): + server = make_fastmcp() + client = FakeClient() + instrument( + server, + client, + MCPAnalyticsOptions(server_build="sha-abc123"), + ) + + list_handler = server._mcp_server.request_handlers[mcp_types.ListToolsRequest] + await list_handler(mcp_types.ListToolsRequest(method="tools/list")) + await server._tool_manager.call_tool("add", {"a": 1, "b": 2}, convert_result=True) + await _flush() + + assert client.events + assert all( + event["properties"]["$mcp_server_build"] == "sha-abc123" + for event in client.events + ) + + async def test_lowlevel_conversation_id_captured_and_prompt_back(): server = make_lowlevel() client = FakeClient() diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 9355e8106..6359ff866 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -47,7 +47,7 @@ async def test_capture_tool_call_success(): async def test_capture_tool_call_error_fans_out_exception(): - client, captured = make_client() + client, captured = make_client(server_build="sha-abc123") client.capture_tool_call( "broken", is_error=True, error=RuntimeError("kaboom"), distinct_id="u" ) @@ -56,6 +56,16 @@ async def test_capture_tool_call_error_fans_out_exception(): assert _events(captured, "$mcp_tool_call")[0]["properties"]["$mcp_is_error"] is True exc = _events(captured, "$exception") assert exc and exc[0]["properties"]["$exception_list"][0]["value"] == "kaboom" + assert all( + event["properties"]["$mcp_server_build"] == "sha-abc123" + for event in [*_events(captured, "$mcp_tool_call"), *exc] + ) + + +@pytest.mark.parametrize("server_build", ["", 123, "x" * 257]) +def test_server_build_validation(server_build): + with pytest.raises((TypeError, ValueError)): + make_client(server_build=server_build) async def test_mcp_events_use_mcp_library_identity(): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 95783ef4c..91b4c26c6 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -795,6 +795,7 @@ attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.PARAMETERS = '$mcp_p attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.PROTOCOL_VERSION = '$mcp_protocol_version' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.RESOURCE_NAME = '$mcp_resource_name' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.RESPONSE = '$mcp_response' +attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.SERVER_BUILD = '$mcp_server_build' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.SERVER_NAME = '$mcp_server_name' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.SERVER_VERSION = '$mcp_server_version' attribute posthog.mcp.constants.PostHogMCPAnalyticsProperty.SESSION_ID = '$session_id' @@ -844,6 +845,7 @@ attribute posthog.mcp.types.MCPAnalyticsOptions.intent_fallback: Optional[Intent attribute posthog.mcp.types.MCPAnalyticsOptions.logger: Optional[LoggerFn] = None attribute posthog.mcp.types.MCPAnalyticsOptions.missing_capability_tool_name: Optional[str] = None attribute posthog.mcp.types.MCPAnalyticsOptions.report_missing: bool = False +attribute posthog.mcp.types.MCPAnalyticsOptions.server_build: Optional[str] = None attribute posthog.mcp.types.PreparedToolCall.args: Optional[JsonRecord] = None attribute posthog.mcp.types.PreparedToolCall.feedback_report: Optional[FeedbackReport] = None attribute posthog.mcp.types.PreparedToolCall.intent: Optional[str] = None @@ -1044,14 +1046,14 @@ class posthog.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent class posthog.mcp.constants.PostHogMCPAnalyticsProperty -class posthog.mcp.posthog_mcp.PostHogMCP(api_key: str, missing_capability_tool_name: Optional[str] = None, mcp_exception_autocapture: bool = True, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, **kwargs: Any) +class posthog.mcp.posthog_mcp.PostHogMCP(api_key: str, missing_capability_tool_name: Optional[str] = None, mcp_exception_autocapture: bool = True, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, server_build: Optional[str] = None, **kwargs: Any) class posthog.mcp.session_token.SessionTokenPayload(session_id: str, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None) class posthog.mcp.types.CaptureEventData(event: str, properties: Optional[JsonRecord] = None) class posthog.mcp.types.CollectFeedbackOptions(tool_name: Optional[str] = None, description: Optional[str] = None, extra_properties: Optional[Dict[str, Dict[str, Any]]] = None, extra_required: Optional[List[str]] = None, on_feedback: Optional[OnFeedbackFn] = None) class posthog.mcp.types.FeedbackReport(feedback_type: str = 'other', summary: str = '', sentiment: Optional[str] = None, friction_points: Optional[str] = None, suggested_improvement: Optional[str] = None, details: Optional[str] = None, tool_name: Optional[str] = None, task_completed: Optional[bool] = None, extras: JsonRecord = dict(), raw: JsonRecord = dict()) class posthog.mcp.types.MCPAnalyticsContextOptions(description: Optional[str] = None) class posthog.mcp.types.MCPAnalyticsModelOptions(description: Optional[str] = None) -class posthog.mcp.types.MCPAnalyticsOptions(logger: Optional[LoggerFn] = None, report_missing: bool = False, missing_capability_tool_name: Optional[str] = None, enable_conversation_id: bool = True, enable_exception_autocapture: bool = True, context: Union[bool, MCPAnalyticsContextOptions] = True, identify: Optional[Union[IdentifyFn, UserIdentity]] = None, intent_fallback: Optional[IntentFallbackFn] = None, before_send: Optional[BeforeSendFn] = None, event_properties: Optional[EventPropertiesFn] = None, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False) +class posthog.mcp.types.MCPAnalyticsOptions(logger: Optional[LoggerFn] = None, report_missing: bool = False, missing_capability_tool_name: Optional[str] = None, enable_conversation_id: bool = True, enable_exception_autocapture: bool = True, context: Union[bool, MCPAnalyticsContextOptions] = True, identify: Optional[Union[IdentifyFn, UserIdentity]] = None, intent_fallback: Optional[IntentFallbackFn] = None, before_send: Optional[BeforeSendFn] = None, event_properties: Optional[EventPropertiesFn] = None, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, server_build: Optional[str] = None) class posthog.mcp.types.PreparedToolCall(args: Optional[JsonRecord] = None, intent: Optional[str] = None, intent_source: Optional[str] = None, is_missing_capability: bool = False, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, is_feedback: bool = False, feedback_report: Optional[FeedbackReport] = None) class posthog.mcp.types.UserIdentity(distinct_id: str, properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None) class posthog.metrics_capture.PostHogMetrics(client, config: Optional[dict] = None) From a876895d72ac49730b07366b1c267e5c94f080e5 Mon Sep 17 00:00:00 2001 From: Georgis Andonis <6430745+gesh@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:00:24 +0300 Subject: [PATCH 2/3] fix(mcp): preserve validated server build Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe --- posthog/mcp/__init__.py | 7 +++++-- posthog/mcp/_capture.py | 2 +- posthog/mcp/_internal.py | 3 +++ posthog/mcp/_posthog_events.py | 12 ++++++++---- posthog/mcp/_truncation.py | 9 +++++++++ posthog/test/mcp/test_features_m4.py | 4 +++- posthog/test/mcp/test_posthog_mcp.py | 6 +++++- posthog/test/mcp/test_truncation.py | 14 ++++++++++++++ 8 files changed, 48 insertions(+), 9 deletions(-) diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index b42cde8bb..e41961c8c 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -249,7 +249,7 @@ def instrument( :param options: Optional :class:`MCPAnalyticsOptions`. """ opts = options or MCPAnalyticsOptions() - validate_server_build(opts.server_build) + server_build = validate_server_build(opts.server_build) # Install the logger first so the version advisory below (and any warning) is # actually visible rather than going to the default no-op sink. @@ -301,7 +301,10 @@ def instrument( if data is None: sink = McpEventSink(client) if client is not None else None data = MCPAnalyticsData( - options=opts, sink=sink, session_id=new_session_id() + options=opts, + sink=sink, + session_id=new_session_id(), + server_build=server_build, ) if is_fastmcp_v2(server) and uses_v2_handler_registry(key): diff --git a/posthog/mcp/_capture.py b/posthog/mcp/_capture.py index bf97ab1e9..ef48ef4a4 100644 --- a/posthog/mcp/_capture.py +++ b/posthog/mcp/_capture.py @@ -45,7 +45,7 @@ def capture_event( "duration": duration, "sdk_language": "Python", "sdk_version": __version__, - "server_build": data.options.server_build, + "server_build": data.server_build, "server_name": data.server_name, "server_version": data.server_version, "client_name": event_input.get("client_name"), diff --git a/posthog/mcp/_internal.py b/posthog/mcp/_internal.py index b10a9ef60..e1cdecef0 100644 --- a/posthog/mcp/_internal.py +++ b/posthog/mcp/_internal.py @@ -101,6 +101,9 @@ class MCPAnalyticsData: # server can't accumulate one entry per session forever. initialized_sessions: "OrderedDict[str, None]" = field(default_factory=OrderedDict) server_name: Optional[str] = None + # Validated once during setup. Keep it separate from the caller-owned + # options object so later option changes cannot alter event attribution. + server_build: Optional[str] = None server_version: Optional[str] = None # A strong wrapper reference would retain the low-level WeakKeyDictionary key. standalone_fastmcp: Optional["weakref.ReferenceType[Any]"] = None diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index d4433b245..90040011a 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -66,6 +66,7 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent: _add_groups(event, properties) _add_common_properties(event, properties) _add_custom_properties(event, properties) + _add_server_build(event, properties) event_name = ( event.get("event_name") or _BUILT_IN_EVENT_NAME_BY_TYPE[event["event_type"]] @@ -126,8 +127,6 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None: properties[_P.DURATION_MS] = event["duration"] if event.get("server_name"): properties[_P.SERVER_NAME] = event["server_name"] - if event.get("server_build"): - properties[_P.SERVER_BUILD] = event["server_build"] if event.get("server_version"): properties[_P.SERVER_VERSION] = event["server_version"] if event.get("client_name"): @@ -254,6 +253,12 @@ def _add_custom_properties(event: Event, properties: Dict[str, Any]) -> None: properties[key] = value +def _add_server_build(event: Event, properties: Dict[str, Any]) -> None: + """Apply the validated build after custom properties so it cannot be replaced.""" + if event.get("server_build"): + properties[_P.SERVER_BUILD] = event["server_build"] + + def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties: Dict[str, Any] = {} _add_session_id(event, properties) @@ -277,8 +282,6 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties[_P.TOOL_CATEGORY] = event["tool_category"] if event.get("server_name"): properties[_P.SERVER_NAME] = event["server_name"] - if event.get("server_build"): - properties[_P.SERVER_BUILD] = event["server_build"] if event.get("server_version"): properties[_P.SERVER_VERSION] = event["server_version"] if event.get("client_name"): @@ -289,6 +292,7 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties[_P.PROTOCOL_VERSION] = event["protocol_version"] _add_custom_properties(event, properties) + _add_server_build(event, properties) return { "event": PostHogMCPAnalyticsEvent.EXCEPTION, diff --git a/posthog/mcp/_truncation.py b/posthog/mcp/_truncation.py index ccf6401c7..674241a6c 100644 --- a/posthog/mcp/_truncation.py +++ b/posthog/mcp/_truncation.py @@ -20,6 +20,8 @@ from datetime import datetime from typing import Any, Dict, List, Optional +from ._server_build import MAX_SERVER_BUILD_LENGTH + MAX_DEPTH = 10 MAX_BREADTH = 100 MAX_STRING_LENGTH = 32_768 # 32KB @@ -250,6 +252,13 @@ def _collect_string_paths( return if isinstance(obj, dict): for key, value in obj.items(): + if ( + not current_path + and key == "server_build" + and isinstance(value, str) + and len(value) <= MAX_SERVER_BUILD_LENGTH + ): + continue _collect_string_paths(value, current_path + [str(key)], results) diff --git a/posthog/test/mcp/test_features_m4.py b/posthog/test/mcp/test_features_m4.py index bafb4ac0c..7ad1882d1 100644 --- a/posthog/test/mcp/test_features_m4.py +++ b/posthog/test/mcp/test_features_m4.py @@ -127,11 +127,13 @@ async def test_fastmcp_conversation_id_captured(): async def test_fastmcp_captures_server_build_on_all_events(): server = make_fastmcp() client = FakeClient() + options = MCPAnalyticsOptions(server_build="sha-abc123") instrument( server, client, - MCPAnalyticsOptions(server_build="sha-abc123"), + options, ) + options.server_build = "changed-after-setup" list_handler = server._mcp_server.request_handlers[mcp_types.ListToolsRequest] await list_handler(mcp_types.ListToolsRequest(method="tools/list")) diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 6359ff866..bf320fb49 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -49,7 +49,11 @@ async def test_capture_tool_call_success(): async def test_capture_tool_call_error_fans_out_exception(): client, captured = make_client(server_build="sha-abc123") client.capture_tool_call( - "broken", is_error=True, error=RuntimeError("kaboom"), distinct_id="u" + "broken", + is_error=True, + error=RuntimeError("kaboom"), + distinct_id="u", + properties={"$mcp_server_build": "custom-value"}, ) await _flush() diff --git a/posthog/test/mcp/test_truncation.py b/posthog/test/mcp/test_truncation.py index cc00c3fef..a0752c6c4 100644 --- a/posthog/test/mcp/test_truncation.py +++ b/posthog/test/mcp/test_truncation.py @@ -145,6 +145,20 @@ def test_truncate_event_enforces_byte_budget_with_many_strings(): assert _json_byte_size(out) <= MAX_EVENT_BYTES +def test_truncate_event_preserves_valid_server_build(): + server_build = "b" * 256 + event = { + "event_type": "$mcp_tool_call", + "server_build": server_build, + "parameters": {f"field_{i}": "z" * 5000 for i in range(60)}, + } + + out = truncate_event(event) + + assert _json_byte_size(out) <= MAX_EVENT_BYTES + assert out["server_build"] == server_build + + def test_truncate_event_caps_single_huge_string_under_budget(): out = truncate_event({"parameters": {"blob": "z" * 300_000}}) assert _json_byte_size(out) <= MAX_EVENT_BYTES From b0fd2bee1cd05406a73a69c4e12ad6673a3ae140 Mon Sep 17 00:00:00 2001 From: Georgis Andonis <6430745+gesh@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:07:57 +0300 Subject: [PATCH 3/3] fix(mcp): add server build to custom events Generated-By: PostHog Desktop Task-Id: dda38523-5b8d-47b6-a11a-64cd59a812fe --- .sampo/changesets/mcp-server-build.md | 2 +- posthog/mcp/README.md | 3 ++- posthog/mcp/posthog_mcp.py | 24 +++++++++++++++++++--- posthog/test/mcp/test_posthog_mcp.py | 29 +++++++++++++++++++++++++++ references/public_api_snapshot.txt | 1 + 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/.sampo/changesets/mcp-server-build.md b/.sampo/changesets/mcp-server-build.md index f6c220875..0bda9791f 100644 --- a/.sampo/changesets/mcp-server-build.md +++ b/.sampo/changesets/mcp-server-build.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Add optional MCP server build metadata. Set `server_build` to record an immutable deployment identifier as `$mcp_server_build` on MCP events from automatic instrumentation and custom dispatchers. +Add optional MCP server build metadata. Set `server_build` to record an immutable deployment identifier as `$mcp_server_build` on MCP events from automatic instrumentation, custom dispatchers, and custom events captured through `PostHogMCP`. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index ad43ea57f..60b729361 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -64,7 +64,8 @@ instrument( ``` The value must contain 1 to 256 characters. The SDK records it as -`$mcp_server_build` on all MCP events. +`$mcp_server_build` on all MCP events. `PostHogMCP.capture()` also adds it to +custom events unless the event provides its own value. Model capture adds an `llm_model` argument to compatible tool schemas, required on the official high-level adapters and optional elsewhere. Dispatch never enforces it, so servers keep working; diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index 7d6c31ca2..399355abe 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -15,7 +15,10 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple, Union -from posthog.client import Client +from typing_extensions import Unpack + +from ..args import OptionalCaptureArgs +from ..client import Client from ._context_parameters import ( add_context_parameter_to_schema, @@ -44,6 +47,7 @@ ) from ._sink import McpCaptureOptions, McpEventSink from ._server_build import validate_server_build +from .constants import PostHogMCPAnalyticsProperty from .feedback import ( build_feedback_event_properties, build_feedback_intent, @@ -71,8 +75,9 @@ class PostHogMCP(Client): """A drop-in posthog ``Client`` with ``capture_tool_call`` / ``capture_initialize`` / ``capture_tools_list`` / ``capture_missing_capability`` / ``capture_feedback`` - plus ``prepare_tool_list`` and ``prepare_tool_call`` helpers. ``capture``, - ``flush``, ``shutdown``, feature flags, etc. all work unchanged.""" + plus ``prepare_tool_list`` and ``prepare_tool_call`` helpers. ``capture`` adds + the configured server build to custom events. Other inherited methods work + unchanged.""" def __init__( self, @@ -136,6 +141,19 @@ def shutdown(self) -> None: # --- capture methods ----------------------------------------------------- + def capture( + self, event: str, **kwargs: Unpack[OptionalCaptureArgs] + ) -> Optional[str]: + """Capture a custom event with the configured server build as a default.""" + if self._server_build is not None: + properties = dict(kwargs.get("properties") or {}) + if properties.get(PostHogMCPAnalyticsProperty.SERVER_BUILD) is None: + properties[PostHogMCPAnalyticsProperty.SERVER_BUILD] = ( + self._server_build + ) + kwargs["properties"] = properties + return super().capture(event, **kwargs) + def capture_tool_call( self, tool_name: str, diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index bf320fb49..cc73ee8a4 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -72,6 +72,35 @@ def test_server_build_validation(server_build): make_client(server_build=server_build) +def test_capture_adds_server_build_to_custom_events(): + captured = [] + + def before_send(event): + captured.append(event) + return event + + client = PostHogMCP( + "phc_test", + send=False, + before_send=before_send, + server_build="default-build", + ) + client.capture("custom event", properties={"existing": True}) + client.capture( + "event build", + properties={"$mcp_server_build": "event-build"}, + ) + client.capture( + "null build", + properties={"$mcp_server_build": None}, + ) + + assert captured[0]["properties"]["existing"] is True + assert captured[0]["properties"]["$mcp_server_build"] == "default-build" + assert captured[1]["properties"]["$mcp_server_build"] == "event-build" + assert captured[2]["properties"]["$mcp_server_build"] == "default-build" + + async def test_mcp_events_use_mcp_library_identity(): captured = [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 91b4c26c6..2da595f95 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1478,6 +1478,7 @@ method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception) method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.mcp.posthog_mcp.PostHogMCP.capture_feedback(*, report: FeedbackReport, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None method posthog.mcp.posthog_mcp.PostHogMCP.capture_initialize(*, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None method posthog.mcp.posthog_mcp.PostHogMCP.capture_missing_capability(*, context: Optional[str] = None, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, parameters: Any = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None