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-capture-tools-list-option.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions posthog/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions posthog/test/mcp/test_capture_tools_list.py
Original file line number Diff line number Diff line change
@@ -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") == []
3 changes: 2 additions & 1 deletion references/public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading