From 641fa39872ea9f23ee9811b4441be3685851498e Mon Sep 17 00:00:00 2001 From: Georgis Andonis Date: Fri, 23 Oct 2026 16:38:32 +0300 Subject: [PATCH] feat(mcp): add a send_tools_list_event option MCPAnalyticsOptions(send_tools_list_event=False) stops $mcp_tools_list. The option is on by default. Clients list tools on every reconnect, so the listing event is about as common as $mcp_tool_call. With the option off, record_tools_list returns early, which also drops a failed listing's $exception. Nothing else changes: tools keep their injected arguments, and the virtual tools stay listed. The name says "event" to match send_initialize_event. PostHogMCP.capture_tools_list() already captures the event by hand. Co-Authored-By: Claude Opus 5.5 --- .../mcp-capture-tools-list-option.md | 5 ++ posthog/mcp/README.md | 3 + posthog/mcp/_instrumentation.py | 2 + posthog/mcp/types.py | 4 ++ .../test/mcp/test_send_tools_list_event.py | 68 +++++++++++++++++++ references/public_api_snapshot.txt | 3 +- 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .sampo/changesets/mcp-capture-tools-list-option.md create mode 100644 posthog/test/mcp/test_send_tools_list_event.py diff --git a/.sampo/changesets/mcp-capture-tools-list-option.md b/.sampo/changesets/mcp-capture-tools-list-option.md new file mode 100644 index 000000000..0208e7620 --- /dev/null +++ b/.sampo/changesets/mcp-capture-tools-list-option.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a `send_tools_list_event` option to MCP analytics. It is on by default. Set it to `False` to stop sending `$mcp_tools_list`. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index f1c0e362c..bd179597d 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -98,6 +98,9 @@ while the option is off. instrument(server, posthog, MCPAnalyticsOptions(send_initialize_event=False)) ``` +Set `send_tools_list_event=False` to stop sending `$mcp_tools_list`. Nothing else changes: tools +keep their injected arguments, and the virtual tools stay listed. + `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. diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index 37877380f..cd87dd4a2 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -1449,6 +1449,8 @@ async def record_tools_list( protocol_version: Optional[str] = None, extra: Optional[Dict[str, Any]] = None, ) -> None: + if not data.options.send_tools_list_event: + return try: event: Dict[str, Any] = { "event_type": MCPAnalyticsEventType.MCP_TOOLS_LIST, diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index 8dbaaf88c..04d1256c8 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -249,6 +249,10 @@ class MCPAnalyticsOptions: # Send `$mcp_initialize` for each `initialize` handshake. On by default. # False sends no `$mcp_initialize`. Nothing else changes. send_initialize_event: bool = True + # Send `$mcp_tools_list` for each `tools/list`. On by default. False sends + # no `$mcp_tools_list`. Nothing else changes: tools keep their injected + # arguments, and the virtual tools stay listed. + send_tools_list_event: bool = True @dataclass diff --git a/posthog/test/mcp/test_send_tools_list_event.py b/posthog/test/mcp/test_send_tools_list_event.py new file mode 100644 index 000000000..7f41b3f5e --- /dev/null +++ b/posthog/test/mcp/test_send_tools_list_event.py @@ -0,0 +1,68 @@ +"""``send_tools_list_event=False`` sends no ``$mcp_tools_list``, and nothing else changes. + +Reuses the real-HTTP fleet from ``test_initialize_handshake.py``, so it runs for +every server shape on both SDK majors. +""" + +from __future__ import annotations + +import pytest + +from posthog.mcp import MCPAnalyticsOptions, instrument +from posthog.test.mcp._helpers import ( + MCP_MAJOR, + FakeClient, + events_named as _events, + flush_background as _flush, +) +from posthog.test.mcp.test_initialize_handshake import ( + KINDS, + call_add, + fleet, + handshake, + list_tools, +) + + +@pytest.mark.parametrize("kind", KINDS) +@pytest.mark.parametrize("capture", [True, False]) +async def test_send_tools_list_event_controls_only_the_listing_event(kind, capture): + client = FakeClient() + options = MCPAnalyticsOptions( + enable_conversation_id=False, send_tools_list_event=capture + ) + async with fleet(client, kind=kind, options=options) as lb: + headers, _ = await handshake(lb) + listing = (await list_tools(lb, headers)).json()["result"] + await call_add(lb, headers) + await _flush() + + assert len(_events(client, "$mcp_tools_list")) == (1 if capture else 0) + # The listing itself is unchanged: the SDK still injects `context`. + (tool,) = [t for t in listing["tools"] if t["name"] == "add"] + assert "context" in tool["inputSchema"]["properties"] + (call,) = _events(client, "$mcp_tool_call") + assert call["properties"]["$mcp_intent"] == "adding" + assert len(_events(client, "$mcp_initialize")) == 1 + + +@pytest.mark.skipif(MCP_MAJOR >= 2, reason="the request_handlers registry is mcp 1.x") +async def test_send_tools_list_event_off_also_drops_a_failed_listing(): + import mcp.types as mcp_types + from mcp.server.lowlevel import Server + + server = Server("failing-listing") + + @server.list_tools() + async def list_tools_handler(): + raise RuntimeError("listing failed") + + client = FakeClient() + instrument(server, client, MCPAnalyticsOptions(send_tools_list_event=False)) + handler = server.request_handlers[mcp_types.ListToolsRequest] + with pytest.raises(RuntimeError): + await handler(mcp_types.ListToolsRequest(method="tools/list")) + await _flush() + + assert _events(client, "$mcp_tools_list") == [] + assert _events(client, "$exception") == [] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 91fb0c168..6c7ff2b92 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1056,6 +1056,7 @@ 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.send_tools_list_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 @@ -1310,7 +1311,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, send_initialize_event: bool = True) +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, send_tools_list_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)