diff --git a/.sampo/changesets/mcp-analytics-defaults.md b/.sampo/changesets/mcp-analytics-defaults.md new file mode 100644 index 00000000..5bb909bf --- /dev/null +++ b/.sampo/changesets/mcp-analytics-defaults.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Enable MCP model capture and conversation correlation by default, including on fresh low-level servers. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index ece6b576..aae31036 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -36,10 +36,24 @@ Request headers use the same identity and package version, so SDK Health can com Because `$lib` is a client-level identity, `instrument()` relabels every event sent by the client passed to it. Use a client dedicated to MCP analytics if the application also captures unrelated events. +## Defaults and opt-outs + +Intent, model capture, conversation correlation, and MCP exception capture are on by default. +Missing-capability reporting and feedback collection remain off. + +```python +instrument(server, posthog, MCPAnalyticsOptions(capture_model=False, enable_conversation_id=False)) +``` + +Conversation correlation adds an optional `conversation_id` argument and returns a handle in +eligible tool results. Clients must echo it to group later calls; calls without it mint new handles. +Set `enable_conversation_id=False` to retain transport-based session grouping and unchanged +response content. Custom `PostHogMCP` dispatchers enable model capture by default but still +supply their own session IDs. + ## Capture the calling model -Model capture is off by default. Enable it for an instrumented MCP Python SDK 1.x or -2.x server: +Model capture is on by default for instrumented MCP Python SDK 1.x and 2.x servers: ```python from posthog.mcp import MCPAnalyticsOptions, instrument @@ -47,7 +61,6 @@ from posthog.mcp import MCPAnalyticsOptions, instrument analytics = instrument( server, posthog, - MCPAnalyticsOptions(capture_model=True), ) ``` @@ -75,6 +88,12 @@ If a tool already declares `llm_model`, or uses a root `$ref`, `oneOf`, `allOf`, `anyOf` schema, PostHog leaves the schema and argument untouched. Client metadata can still be captured in those cases. +On fresh low-level instances, model argument ownership is resolved from the original tool +listing before dispatch. This internal lookup emits no discovery event and stops after 16 pages +or 250 ms. If the listing fails or omits the tool, its arguments remain unchanged and self-reported +model capture stays empty; recognized client metadata can still supply the model. Existing +listings and high-level registries continue to supply ownership directly. + For a custom dispatcher, use the same option on `PostHogMCP` and pass request metadata through explicitly: @@ -229,7 +248,7 @@ sess = get_mcp_session(request) # sess.session_id, sess.client_name, ... ### Or skip the middleware entirely: conversation ids -`MCPAnalyticsOptions(enable_conversation_id=True)` derives `$session_id` from the +`enable_conversation_id` is on by default and derives `$session_id` from the agent's conversation handle, deterministically and identically on every pod. That needs no middleware and no ordering discipline, and it is the only thing that correlates a session under the 2026-07-28 revision's per-request server instances. diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index 78659793..18a84169 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -42,6 +42,7 @@ ) from ._internal import MCPAnalyticsData from ._model_parameters import request_meta_from_context +from ._tool_schema import resolve_model_ownership from ._output_instructions import mirror_instructions_into_structured_content from .logger import log from .tools import get_more_tools_result_text, resolve_missing_capability_tool_name @@ -171,6 +172,31 @@ async def handler(req: Any) -> Any: handlers[request_type] = handler +async def _prepare_model_arguments( + server: Any, data: MCPAnalyticsData, req: Any, strip_injected: bool +) -> Tuple[Any, bool]: + async def list_page(cursor: Optional[str]) -> Any: + listing = server.request_handlers.get(mcp_types.ListToolsRequest) + raw = getattr(listing, "__posthog_mcp_original__", listing) + return await raw( + mcp_types.ListToolsRequest( + method="tools/list", + params=mcp_types.PaginatedRequestParams( + cursor=cursor, _meta=req.params.meta + ), + ) + ) + + owns_model = await resolve_model_ownership(data, req.params.name, list_page) + if strip_injected or not owns_model: + return req, owns_model + arguments = dict(req.params.arguments or {}) + arguments.pop("llm_model", None) + return req.model_copy( + update={"params": req.params.model_copy(update={"arguments": arguments})} + ), owns_model + + def _wrap_call_tool( server: Any, data: MCPAnalyticsData, *, strip_injected: bool, high_level: Any = None ) -> None: @@ -182,6 +208,10 @@ def _wrap_call_tool( async def handler(req: Any) -> Any: name = req.params.name arguments = dict(req.params.arguments or {}) + + req, analytics_owns_model = await _prepare_model_arguments( + server, data, req, strip_injected + ) client_name, client_version = _client_info(server) protocol_version = _protocol_version(server) mcp_session_id = _mcp_session_id(server) @@ -195,9 +225,7 @@ async def handler(req: Any) -> Any: name=name, arguments=arguments, request_meta=request_meta_from_context(_request_context(server)), - allow_self_reported_model=data.tool_model_parameter_injected.get( - name, False - ), + allow_self_reported_model=analytics_owns_model, mcp_session_id=mcp_session_id, token=token, client_name=client_name, @@ -240,7 +268,7 @@ async def handler(req: Any) -> Any: if strip_injected and req.params.arguments: owned = await _tool_owned_injected_keys(high_level, name) injected_keys = ["context", "conversation_id"] - if data.tool_model_parameter_injected.get(name, False): + if analytics_owns_model: injected_keys.append("llm_model") for key in injected_keys: if key not in owned: @@ -424,6 +452,7 @@ async def handler(req: Any) -> Any: return result + setattr(handler, "__posthog_mcp_original__", original) setattr(handler, _WRAPPED_FLAG, True) handlers[mcp_types.ListToolsRequest] = handler diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index 66a28e77..dc788594 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -61,6 +61,7 @@ request_meta_from_context, ) from ._output_instructions import mirror_instructions_into_structured_content +from ._tool_schema import resolve_model_ownership from .logger import log from .request_headers import get_request_headers from .session_token import read_mcp_session_header @@ -477,6 +478,40 @@ async def _standalone_injected_parameters( return frozenset(key for key in injected if not schema_has_param(schema, key)) +async def _prepare_raw_v2_arguments( + server: Any, data: MCPAnalyticsData, ctx: Any, params: Any +) -> Tuple[Any, bool]: + async def list_page(cursor: Optional[str]) -> Any: + listing = server.get_request_handler(_LIST_METHOD) + raw = getattr(listing.handler, "__posthog_mcp_original__", listing.handler) + return await raw(ctx, mcp_types.PaginatedRequestParams(cursor=cursor)) + + owns_model = await resolve_model_ownership(data, params.name, list_page) + if not owns_model: + return params, False + arguments = dict(params.arguments or {}) + arguments.pop("llm_model", None) + return params.model_copy(update={"arguments": arguments}), True + + +async def _prepare_v2_arguments( + server: Any, data: MCPAnalyticsData, ctx: Any, params: Any +) -> Tuple[Any, bool]: + standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None + if standalone is None: + return await _prepare_raw_v2_arguments(server, data, ctx, params) + version = _requested_tool_version(ctx) + injected = await _standalone_injected_parameters( + standalone, data, params.name, version + ) + if injected is None: + return params, False + arguments = dict(params.arguments or {}) + for key in injected: + arguments.pop(key, None) + return params.model_copy(update={"arguments": arguments}), "llm_model" in injected + + def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None: entry = server.get_request_handler(_CALL_METHOD) if entry is None or getattr(entry.handler, _WRAPPED_FLAG, False): @@ -486,21 +521,10 @@ def _wrap_v2_call_tool(server: Any, data: MCPAnalyticsData) -> None: async def handler(ctx: Any, params: Any) -> Any: name = params.name arguments = dict(params.arguments or {}) - analytics_owns_model = data.tool_model_parameter_injected.get(name, False) - standalone = data.standalone_fastmcp() if data.standalone_fastmcp else None - if standalone is not None: - version = _requested_tool_version(ctx) - injected = await _standalone_injected_parameters( - standalone, data, name, version - ) - analytics_owns_model = injected is not None and "llm_model" in injected - if injected is not None: - call_arguments = { - key: value - for key, value in arguments.items() - if key not in injected - } - params = params.model_copy(update={"arguments": call_arguments}) + + params, analytics_owns_model = await _prepare_v2_arguments( + server, data, ctx, params + ) token, client_name, client_version, protocol_version, mcp_session_id = ( _resolve_ctx(ctx) ) @@ -715,6 +739,7 @@ async def handler(ctx: Any, params: Any) -> Any: return result + setattr(handler, "__posthog_mcp_original__", original) setattr(handler, _WRAPPED_FLAG, True) _replace_handler(server, _LIST_METHOD, handler, entry.params_type) diff --git a/posthog/mcp/_tool_schema.py b/posthog/mcp/_tool_schema.py new file mode 100644 index 00000000..b6aa046c --- /dev/null +++ b/posthog/mcp/_tool_schema.py @@ -0,0 +1,71 @@ +"""Resolve model argument ownership on low-level servers without a tool registry.""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Optional + +from ._internal import MCPAnalyticsData +from ._model_parameters import can_inject_model_parameter, is_capture_model_enabled +from .logger import log + + +async def resolve_model_ownership( + data: MCPAnalyticsData, + name: str, + list_page: Callable[[Optional[str]], Awaitable[Any]], +) -> bool: + if not is_capture_model_enabled(data.options.capture_model): + return False + if name in data.tool_model_parameter_injected: + return data.tool_model_parameter_injected[name] + try: + ownership = await asyncio.wait_for( + _find_model_ownership(name, list_page), timeout=0.25 + ) + if ownership is not None: + data.tool_model_parameter_injected[name] = ownership + return ownership if ownership is not None else False + except Exception: # noqa: BLE001 - discovery must not prevent tool dispatch + log( + "Warning: Could not resolve model argument ownership; leaving tool arguments unchanged." + ) + return False + + +async def _find_model_ownership( + name: str, list_page: Callable[[Optional[str]], Awaitable[Any]] +) -> Optional[bool]: + cursor = None + seen = set() + for _ in range(16): + response = await list_page(cursor) + result = getattr(response, "root", response) + ownership = _model_ownership(result, name) + if ownership is not None: + return ownership + cursor = _next_cursor(result) + if cursor is None or cursor in seen: + return None + seen.add(cursor) + return None + + +def _model_ownership(result: Any, name: str) -> Optional[bool]: + for tool in getattr(result, "tools", []): + if getattr(tool, "name", None) == name: + schema = getattr(tool, "input_schema", None) + if schema is None: + schema = getattr(tool, "inputSchema", None) + return can_inject_model_parameter(schema) + return None + + +def _next_cursor(result: Any) -> Optional[str]: + if hasattr(result, "next_cursor"): + cursor = result.next_cursor + else: + cursor = getattr(result, "nextCursor", None) + if not isinstance(cursor, str): + return None + return cursor or None diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index 614b9615..a461e046 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -71,7 +71,7 @@ def __init__( api_key: str, missing_capability_tool_name: Optional[str] = None, mcp_exception_autocapture: bool = True, - capture_model: Union[bool, MCPAnalyticsModelOptions] = False, + capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, **kwargs: Any, ) -> None: diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index d2f3afdd..fb37a73e 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -183,7 +183,7 @@ class MCPAnalyticsOptions: logger: Optional[LoggerFn] = None report_missing: bool = False missing_capability_tool_name: Optional[str] = None - enable_conversation_id: bool = False + enable_conversation_id: bool = True enable_exception_autocapture: bool = True # Inject a required `context` parameter on every tool to capture user intent. context: Union[bool, MCPAnalyticsContextOptions] = True @@ -197,8 +197,8 @@ class MCPAnalyticsOptions: # Extra properties merged onto every auto-captured event. event_properties: Optional[EventPropertiesFn] = None # Capture the model from recognized client metadata, falling back to an - # SDK-injected llm_model argument. Off by default. - capture_model: Union[bool, MCPAnalyticsModelOptions] = False + # SDK-injected llm_model argument. On by default; False disables capture. + capture_model: Union[bool, MCPAnalyticsModelOptions] = True # Inject the `send_feedback` virtual tool so agents can send feedback about # this server to its developers — a missing capability (the priority # category), a tool that failed or confused them, or praise. Calls to it emit diff --git a/posthog/test/mcp/test_defaults.py b/posthog/test/mcp/test_defaults.py new file mode 100644 index 00000000..24270b46 --- /dev/null +++ b/posthog/test/mcp/test_defaults.py @@ -0,0 +1,79 @@ +import pytest + +from posthog.mcp.types import MCPAnalyticsOptions +from posthog.test.mcp._helpers import ( + MCP_MAJOR, + FakeClient, + events_named, + flush_background, +) + + +@pytest.mark.skipif(MCP_MAJOR < 2, reason="v2 low-level handler API") +async def test_default_capture_on_fresh_lowlevel_instances(): + from posthog.mcp import instrument + from posthog.test.mcp.test_v2_lowlevel import make_server, _call_tool, _list_tools + + client = FakeClient() + + def fresh(): + server = make_server() + instrument(server, client) + return server + + listing = await _list_tools(fresh()) + assert [tool.name for tool in listing.tools] == ["add"] + assert {"context", "llm_model", "conversation_id"} <= set( + listing.tools[0].input_schema["properties"] + ) + await _call_tool( + fresh(), "add", {"a": 1, "b": 2, "context": "intent", "llm_model": "model-a"} + ) + await flush_background() + first = events_named(client, "$mcp_tool_call")[0]["properties"] + await _call_tool( + fresh(), + "add", + { + "a": 2, + "b": 3, + "context": "intent", + "llm_model": "model-a", + "conversation_id": first["$mcp_conversation_id"], + }, + ) + await flush_background() + calls = events_named(client, "$mcp_tool_call") + assert len(calls) == 2 + assert first["$mcp_llm_model"] == "model-a" + assert first["$session_id"] == calls[1]["properties"]["$session_id"] + assert len(events_named(client, "$mcp_tools_list")) == 1 + + +@pytest.mark.skipif(MCP_MAJOR >= 2, reason="v1 low-level handler API") +@pytest.mark.parametrize("enabled", [True, False]) +async def test_v1_cold_capture_and_opt_out(enabled): + import mcp.types as types + from posthog.mcp import instrument + from posthog.test.mcp.test_lowlevel import make_server, _call_request + + client = FakeClient() + server = make_server() + options = ( + None + if enabled + else MCPAnalyticsOptions(capture_model=False, enable_conversation_id=False) + ) + instrument(server, client, options) + request = _call_request("echo", {"msg": "ok", "llm_model": "model-a"}) + result = await server.request_handlers[types.CallToolRequest](request) + await flush_background() + calls = events_named(client, "$mcp_tool_call") + assert len(calls) == 1 + assert not calls[0]["properties"]["$mcp_is_error"] + assert calls[0]["properties"].get("$mcp_llm_model") == ( + "model-a" if enabled else None + ) + assert len(result.root.content) == (2 if enabled else 1) + assert len(events_named(client, "$mcp_tools_list")) == 0 + assert request.params.arguments == {"msg": "ok", "llm_model": "model-a"} diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index 992c558a..ac84541c 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -377,7 +377,7 @@ async def test_tool_call_error_captured_from_is_error_result(): async def test_initialize_emitted_once(): server = make_server() client = FakeClient() - instrument(server, client) + instrument(server, client, MCPAnalyticsOptions(enable_conversation_id=False)) handler = server.request_handlers[mcp_types.CallToolRequest] await handler(_call_request("echo", {"msg": "a", "context": "first call"})) diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 7b9b89fc..9355e810 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -222,7 +222,7 @@ def test_prepare_tool_list_can_be_disabled(): ) async def test_prepare_and_capture_model(options: dict[str, bool]) -> None: client, captured = make_client(**options) - enabled = options.get("capture_model", False) + enabled = options.get("capture_model", True) tools = [ { "name": "search", diff --git a/posthog/test/mcp/test_session_token.py b/posthog/test/mcp/test_session_token.py index 10d79cba..4cb0ac81 100644 --- a/posthog/test/mcp/test_session_token.py +++ b/posthog/test/mcp/test_session_token.py @@ -645,7 +645,7 @@ def test_runtime_warns_when_app_was_built_before_instrument(): app = srv.streamable_http_app() # BEFORE instrument() -- the trap with _captured_logs() as logs: - instrument(srv, _Sink()) + instrument(srv, _Sink(), MCPAnalyticsOptions(enable_conversation_id=False)) with TestClient(app) as client: resp = _call_ping(client) assert resp.status_code == 200, resp.text @@ -716,7 +716,7 @@ def test_warnings_are_visible_without_configuring_a_logger(caplog): set_logger(None) # explicitly no `logger` option anywhere with caplog.at_level("WARNING", logger="posthog.mcp"): - instrument(srv, _Sink()) + instrument(srv, _Sink(), MCPAnalyticsOptions(enable_conversation_id=False)) with TestClient(app) as client: _call_ping(client) diff --git a/posthog/test/mcp/test_tool_schema.py b/posthog/test/mcp/test_tool_schema.py new file mode 100644 index 00000000..f45831cf --- /dev/null +++ b/posthog/test/mcp/test_tool_schema.py @@ -0,0 +1,89 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from posthog.mcp._internal import MCPAnalyticsData +from posthog.mcp._tool_schema import resolve_model_ownership +from posthog.mcp.types import MCPAnalyticsOptions + + +def data(): + return MCPAnalyticsData(options=MCPAnalyticsOptions()) + + +@pytest.mark.parametrize("owned", [True, False]) +async def test_paginated_catalog_caches_confirmed_model_ownership(owned): + schema = { + "type": "object", + "properties": {"llm_model": {"type": "string"}} if owned else {}, + } + tool = SimpleNamespace(name="echo", input_schema=schema) + listing = AsyncMock( + side_effect=[ + SimpleNamespace(tools=[], next_cursor="next"), + SimpleNamespace(tools=[tool]), + ] + ) + state = data() + for _ in range(2): + assert await resolve_model_ownership(state, "echo", listing) is not owned + assert [call.args for call in listing.call_args_list] == [(None,), ("next",)] + + +async def test_missing_tool_is_retried_when_it_appears(): + tool = SimpleNamespace(name="echo", input_schema={"type": "object"}) + listing = AsyncMock( + side_effect=[SimpleNamespace(tools=[]), SimpleNamespace(tools=[tool])] + ) + state = data() + assert await resolve_model_ownership(state, "echo", listing) is False + assert "echo" not in state.tool_model_parameter_injected + assert await resolve_model_ownership(state, "echo", listing) is True + assert listing.call_count == 2 + + +@pytest.mark.parametrize( + "mode,expected_calls", + [("cycle", 2), ("endless", 16), ("malformed", 1), ("error", 1)], +) +async def test_bounded_catalog_failures(mode, expected_calls): + calls = [] + + async def listing(cursor): + calls.append(cursor) + if mode == "error": + raise ValueError("unavailable") + if mode == "malformed": + return None + return SimpleNamespace( + tools=[], next_cursor="same" if mode == "cycle" else str(len(calls)) + ) + + state = data() + assert await resolve_model_ownership(state, "echo", listing) is False + assert "echo" not in state.tool_model_parameter_injected + assert len(calls) == expected_calls + + +async def test_slow_listing_does_not_block_dispatch(): + cancelled = asyncio.Event() + + async def listing(cursor): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + state = data() + assert await resolve_model_ownership(state, "echo", listing) is False + assert "echo" not in state.tool_model_parameter_injected + assert cancelled.is_set() + + +async def test_opt_out_does_not_invoke_catalog(): + state = MCPAnalyticsData(options=MCPAnalyticsOptions(capture_model=False)) + listing = AsyncMock() + assert await resolve_model_ownership(state, "echo", listing) is False + listing.assert_not_called() diff --git a/posthog/test/mcp/test_units.py b/posthog/test/mcp/test_units.py index 998510df..e8312123 100644 --- a/posthog/test/mcp/test_units.py +++ b/posthog/test/mcp/test_units.py @@ -109,7 +109,7 @@ def test_schema_pipeline_does_not_warn_for_owned_conversation_id(monkeypatch): tool = SimpleNamespace(name="t", input_schema=schema) mutate_tool_schema( - _data(context=False, enable_conversation_id=True), + _data(context=False, capture_model=False, enable_conversation_id=True), tool, schema_attribute="input_schema", owns_context=False, diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index 4d53407e..e3ad65c7 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -453,7 +453,7 @@ async def late_read_resource(ctx, params): async def test_initialize_and_session_reuse_across_calls(): server = make_server() client = FakeClient() - instrument(server, client) + instrument(server, client, MCPAnalyticsOptions(enable_conversation_id=False)) await _call_tool(server, "add", {"a": 1, "b": 1, "context": "first"}) await _call_tool(server, "add", {"a": 2, "b": 2, "context": "second"}) diff --git a/posthog/test/mcp/test_v2_mcpserver.py b/posthog/test/mcp/test_v2_mcpserver.py index 80ae82ce..890e342c 100644 --- a/posthog/test/mcp/test_v2_mcpserver.py +++ b/posthog/test/mcp/test_v2_mcpserver.py @@ -175,7 +175,7 @@ async def test_tool_call_error_is_captured_and_converted(): async def test_initialize_emitted_once_per_session(): server = make_server() client = FakeClient() - instrument(server, client) + instrument(server, client, MCPAnalyticsOptions(enable_conversation_id=False)) await _call_tool( server, "add", {"a": 1, "b": 1, "context": "first call to warm up"} diff --git a/posthog/test/mcp/test_v2_wire_dual_era.py b/posthog/test/mcp/test_v2_wire_dual_era.py index 4b7c11b3..0e89b69b 100644 --- a/posthog/test/mcp/test_v2_wire_dual_era.py +++ b/posthog/test/mcp/test_v2_wire_dual_era.py @@ -227,7 +227,9 @@ async def test_modern_result_shape_survives_instrumentation(): bare_response = await modern_call(http, "add", {"a": 4, "b": 5}) instrumented = make_server() - instrument(instrumented, FakeClient()) + instrument( + instrumented, FakeClient(), MCPAnalyticsOptions(enable_conversation_id=False) + ) async with wire(instrumented) as http: response = await modern_call(http, "add", {"a": 4, "b": 5, "context": "alive"}) await _flush() @@ -295,7 +297,7 @@ async def test_legacy_stateless_token_survives_across_instances(): # Pod A mints the token on initialize. server_a = make_server() - instrument(server_a, client) + instrument(server_a, client, MCPAnalyticsOptions(enable_conversation_id=False)) async with wire(server_a) as http: init = rpc( "initialize", @@ -316,7 +318,7 @@ async def test_legacy_stateless_token_survives_across_instances(): # A compliant legacy client replays both the session header and the # negotiated MCP-Protocol-Version on every subsequent request. server_b = make_server() - instrument(server_b, client) + instrument(server_b, client, MCPAnalyticsOptions(enable_conversation_id=False)) async with wire(server_b) as http: headers = { **legacy_headers(), diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index bf7211fb..843003ab 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -818,10 +818,10 @@ attribute posthog.mcp.types.MCPAnalyticsContextOptions.description: Optional[str attribute posthog.mcp.types.MCPAnalyticsModelOptions.description: Optional[str] = None attribute posthog.mcp.types.MCPAnalyticsModelSource = Literal['client_metadata', 'self_reported'] attribute posthog.mcp.types.MCPAnalyticsOptions.before_send: Optional[BeforeSendFn] = None -attribute posthog.mcp.types.MCPAnalyticsOptions.capture_model: Union[bool, MCPAnalyticsModelOptions] = False +attribute posthog.mcp.types.MCPAnalyticsOptions.capture_model: Union[bool, MCPAnalyticsModelOptions] = 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 = False +attribute posthog.mcp.types.MCPAnalyticsOptions.enable_conversation_id: bool = True attribute posthog.mcp.types.MCPAnalyticsOptions.enable_exception_autocapture: bool = True attribute posthog.mcp.types.MCPAnalyticsOptions.event_properties: Optional[EventPropertiesFn] = None attribute posthog.mcp.types.MCPAnalyticsOptions.identify: Optional[Union[IdentifyFn, UserIdentity]] = None @@ -1021,14 +1021,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] = False, 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, **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 = False, 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] = False, 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) 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)