Skip to content
Merged
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-capture-initialize-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Add a `send_initialize_event` option to MCP analytics. It is on by default. Set it to `False` to stop sending `$mcp_initialize`.
14 changes: 12 additions & 2 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ Use a client dedicated to MCP analytics if the application also captures unrelat
A valid echoed conversation handle takes precedence over transport sessions. A request that already carries a transport session or a PostHog session token does not mint a new handle or append a prompt-back block. Requests carrying neither use the conversation fallback.


Intent, model capture, conversation correlation, and MCP exception capture are on by default.
Missing-capability reporting and feedback collection remain off.
Intent, model capture, conversation correlation, MCP exception capture, and `$mcp_initialize`
are on by default. Missing-capability reporting and feedback collection remain off.

```python
from posthog.mcp import MCPAnalyticsOptions, instrument
Expand Down Expand Up @@ -88,6 +88,16 @@ The MCP SDK answers `initialize` itself, so the SDK observes it from outside:
- **MCP SDK 1.x over stdio**: from the session's first request. A stdio server
serves one client, so this misses only a client that never sends a request.

Set `send_initialize_event=False` to stop sending `$mcp_initialize`. Many clients reconnect before
every few tool calls, so this event can outnumber `$mcp_tool_call`. Nothing else changes in what
the SDK sends: tool calls keep their sessions and client names. In PostHog, MCP onboarding and the
users and sessions tiles of the default MCP dashboard read `$mcp_initialize`. They show no data
while the option is off.

```python
instrument(server, posthog, MCPAnalyticsOptions(send_initialize_event=False))
```

`identify` and `event_properties` run for the handshake too. On MCP SDK 1.x the
SDK has no request context for it, so `extra["ctx"]` carries only `.request`.
`get_request_headers(extra)` works the same as for any other request.
Expand Down
11 changes: 9 additions & 2 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,19 @@ async def record_initialize(
stateless fleet may never see this client again. The capture itself runs after,
in the background, as for every other event."""

request = {"method": "initialize", "params": params}
await handle_identify(data, session_id, request, extra)
# The option stops only the event. Identify still runs, so later events of
# the session keep an identity that only the handshake can resolve.
if not data.options.send_initialize_event:
return

def text(value: Any) -> Optional[str]:
return value if isinstance(value, str) and value else None

client_info = params.get("clientInfo")
client_info = client_info if isinstance(client_info, dict) else {}
agreed = result.get("protocolVersion") if isinstance(result, dict) else None
request = {"method": "initialize", "params": params}
await handle_identify(data, session_id, request, extra)
data.mark_session_initialized(session_id)
event: Dict[str, Any] = {
"event_type": MCPAnalyticsEventType.MCP_INITIALIZE,
Expand Down Expand Up @@ -321,6 +326,8 @@ async def _maybe_emit_initialize(
"""Emit ``$mcp_initialize`` once per session from its first request, where no
seam observes the real ``initialize`` (see
:func:`_first_request_stands_in_for_initialize`)."""
if not data.options.send_initialize_event:
return
if not _first_request_stands_in_for_initialize(data, session_source, extra):
return
if session_id in data.initialized_sessions:
Expand Down
3 changes: 3 additions & 0 deletions posthog/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ class MCPAnalyticsOptions:
# its input schema to remove only PostHog-owned arguments on a fresh server
# instance that did not serve tools/list.
resolve_original_tool: Optional[Callable[[str], Any]] = None
# Send `$mcp_initialize` for each `initialize` handshake. On by default.
# False sends no `$mcp_initialize`. Nothing else changes.
send_initialize_event: bool = True


@dataclass
Expand Down
74 changes: 74 additions & 0 deletions posthog/test/mcp/test_initialize_handshake.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,3 +988,77 @@ async def test_a_nested_method_before_the_top_level_initialize_is_counted(kind):
assert response.status_code == 200, response.text
(init,) = _events(client, "$mcp_initialize")
assert init["properties"]["$mcp_client_name"] == "nested-client"


# --- send_initialize_event=False -----------------------------------------------------


@pytest.mark.parametrize("kind", KINDS)
async def test_send_initialize_event_off_sends_no_initialize_and_keeps_sessions(kind):
# The multi-pod scenario with the option off: no $mcp_initialize. Nothing else
# changes: the token is still minted, so each connection's tool calls keep its
# session and its client name on every pod.
client = FakeClient()
options = MCPAnalyticsOptions(
enable_conversation_id=False, send_initialize_event=False
)
sessions = []
async with fleet(client, pods=2, kind=kind, options=options) as lb:
for _ in range(2):
headers, _ = await handshake(lb)
token = decode_session_id(headers.get("mcp-session-id"))
assert token is not None
sessions.append(token.session_id)
await list_tools(lb, headers)
await call_add(lb, headers, 3)
await call_add(lb, headers, 4)
await _flush()

assert _events(client, "$mcp_initialize") == []
calls = _events(client, "$mcp_tool_call")
assert [c["properties"]["$session_id"] for c in calls] == [
sessions[0],
sessions[0],
sessions[1],
sessions[1],
]
assert {c["properties"]["$mcp_client_name"] for c in calls} == {"codex-mcp-client"}


@pytest.mark.parametrize("kind", KINDS)
async def test_send_initialize_event_off_keeps_the_handshake_identity(kind):
# The option stops only the event. An identify that resolves the user only
# from the handshake still attributes the session's later tool calls.
def identify(request, extra):
if request.get("method") == "initialize":
return UserIdentity(distinct_id="user_from_handshake")
return None

client = FakeClient()
options = MCPAnalyticsOptions(
enable_conversation_id=False, identify=identify, send_initialize_event=False
)
async with fleet(client, kind=kind, options=options) as lb:
headers, _ = await handshake(lb)
await call_add(lb, headers)
await _flush()

assert _events(client, "$mcp_initialize") == []
(call,) = _events(client, "$mcp_tool_call")
assert call["distinct_id"] == "user_from_handshake"


@pytest.mark.skipif(MCP_MAJOR >= 2, reason="the first-request fallback is mcp 1.x")
@pytest.mark.parametrize("capture", [True, False])
async def test_send_initialize_event_off_also_gates_the_first_request_fallback(capture):
# No HTTP, as on stdio: the first tool call stands in for the handshake.
client = FakeClient()
options = MCPAnalyticsOptions(
enable_conversation_id=False, send_initialize_event=capture
)
_, _, server = make_pod(client, options=options)
await server._tool_manager.call_tool("add", {"a": 1, "b": 2, "context": "stdio"})
await _flush()

assert len(_events(client, "$mcp_tool_call")) == 1
assert len(_events(client, "$mcp_initialize")) == (1 if capture else 0)
3 changes: 2 additions & 1 deletion references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,7 @@ attribute posthog.mcp.types.MCPAnalyticsOptions.missing_capability_tool_name: Op
attribute posthog.mcp.types.MCPAnalyticsOptions.report_missing: bool = False
attribute posthog.mcp.types.MCPAnalyticsOptions.resolve_input_aliases: Optional[ResolveInputAliasesFn] = None
attribute posthog.mcp.types.MCPAnalyticsOptions.resolve_original_tool: Optional[Callable[[str], Any]] = None
attribute posthog.mcp.types.MCPAnalyticsOptions.send_initialize_event: bool = True
attribute posthog.mcp.types.MCPAnalyticsOptions.server_build: Optional[str] = None
attribute posthog.mcp.types.MCPAnalyticsOptions.should_record_input_key: Optional[ShouldRecordInputKeyFn] = None
attribute posthog.mcp.types.PreparedToolCall.args: Optional[JsonRecord] = None
Expand Down Expand Up @@ -1306,7 +1307,7 @@ class posthog.mcp.types.CollectFeedbackOptions(tool_name: Optional[str] = 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, server_build: Optional[str] = None, should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, resolve_input_aliases: Optional[ResolveInputAliasesFn] = None, resolve_original_tool: Optional[Callable[[str], Any]] = 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, server_build: Optional[str] = None, should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, resolve_input_aliases: Optional[ResolveInputAliasesFn] = None, resolve_original_tool: Optional[Callable[[str], Any]] = None, send_initialize_event: bool = True)
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, session_id: Optional[str] = None, conversation_id: Optional[str] = None, _conversation_state: Optional[PreparedConversationState] = None)
class posthog.mcp.types.ToolInputOptions(should_record_input_key: Optional[ShouldRecordInputKeyFn] = None, input_aliases: Optional[InputAliasMap] = None)
class posthog.mcp.types.UserIdentity(distinct_id: str, properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None)
Expand Down
Loading