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 00000000..3a196d78 --- /dev/null +++ b/.sampo/changesets/mcp-capture-tools-list-option.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a `capture_tools_list` 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 0c936ba5..cf277be0 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -96,6 +96,9 @@ calls keep their sessions and client names. instrument(server, posthog, MCPAnalyticsOptions(capture_initialize=False)) ``` +Set `capture_tools_list=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 9580e3a0..c325dd68 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.capture_tools_list: + 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 b97a640f..8d253da5 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. capture_initialize: 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. + capture_tools_list: bool = True @dataclass diff --git a/posthog/test/mcp/test_capture_tools_list.py b/posthog/test/mcp/test_capture_tools_list.py new file mode 100644 index 00000000..c7067c58 --- /dev/null +++ b/posthog/test/mcp/test_capture_tools_list.py @@ -0,0 +1,68 @@ +"""``capture_tools_list=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_capture_tools_list_controls_only_the_listing_event(kind, capture): + client = FakeClient() + options = MCPAnalyticsOptions( + enable_conversation_id=False, capture_tools_list=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_capture_tools_list_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(capture_tools_list=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 ad77deca..22f33b1f 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1041,6 +1041,7 @@ attribute posthog.mcp.types.MCPAnalyticsModelSource = Literal['client_metadata', attribute posthog.mcp.types.MCPAnalyticsOptions.before_send: Optional[BeforeSendFn] = None attribute posthog.mcp.types.MCPAnalyticsOptions.capture_initialize: bool = True attribute posthog.mcp.types.MCPAnalyticsOptions.capture_model: Union[bool, MCPAnalyticsModelOptions] = True +attribute posthog.mcp.types.MCPAnalyticsOptions.capture_tools_list: bool = True attribute posthog.mcp.types.MCPAnalyticsOptions.collect_feedback: Union[bool, CollectFeedbackOptions] = False attribute posthog.mcp.types.MCPAnalyticsOptions.context: Union[bool, MCPAnalyticsContextOptions] = True attribute posthog.mcp.types.MCPAnalyticsOptions.enable_conversation_id: bool = True @@ -1307,7 +1308,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, capture_initialize: 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, capture_initialize: bool = True, capture_tools_list: 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)