diff --git a/.sampo/changesets/mcp-dispatcher-conversations.md b/.sampo/changesets/mcp-dispatcher-conversations.md new file mode 100644 index 000000000..1dca7a6d2 --- /dev/null +++ b/.sampo/changesets/mcp-dispatcher-conversations.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add conversation and session correlation to custom `PostHogMCP` dispatchers, matching `@posthog/mcp`. `prepare_tool_list()` adds an optional `conversation_id` argument and a compatible `_mcp_instructions` output field, `prepare_tool_call()` accepts a carried `session_id` and returns the resolved `session_id` and `conversation_id`, and the new `prepare_tool_result()` delivers a minted handle without changing the original result. The capture methods accept `conversation_id`. Set `PostHogMCP(enable_conversation_id=False)` to keep the previous behavior. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 287484dc8..5f08cd735 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -56,8 +56,8 @@ strict-schema clients see the new field. Set `capture_model=False` to leave sche 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. The reasoning is recorded in posthog-js `docs/adr/0013`. +response content. Custom `PostHogMCP` dispatchers also enable model capture and conversation +correlation by default. The reasoning is recorded in posthog-js `docs/adr/0013`. ## Capture the calling model @@ -102,8 +102,10 @@ authorization middleware with those hooks; argument-based model capture is skipp application-owned value cannot be mistaken for analytics. No additional catalog lookup runs during a tool call. -For a custom dispatcher, `PostHogMCP` enables the same option by default; pass request -metadata through explicitly: +For a custom dispatcher, `PostHogMCP` enables model capture and conversation correlation +by default. `prepare_tool_list()` injects the analytics fields and records ownership by tool +name. `prepare_tool_call()` removes SDK-owned arguments and resolves the conversation and +session. `prepare_tool_result()` returns the result to send and the final values to capture: ```python from posthog.mcp import PostHogMCP @@ -116,22 +118,34 @@ call = posthog.prepare_tool_call( raw_args, request_meta=request.get("params", {}).get("_meta"), original_tool=original_tool, + session_id=transport_session_id, ) -result = dispatch(tool_name, call.args) +prepared = posthog.prepare_tool_result(dispatch(tool_name, call.args), call) posthog.capture_tool_call( tool_name, llm_model=call.llm_model, llm_model_source=call.llm_model_source, + session_id=prepared.session_id, + conversation_id=prepared.conversation_id, ) +return prepared.result ``` Passing `original_tool` keeps ownership accurate when `tools/list` and `tools/call` reach different server replicas. A persistent single-process dispatcher can omit it after calling `prepare_tool_list()`. -Model injection copies tool objects instead of changing their original schemas. +Model and conversation injection copy tool objects instead of changing their original schemas. Always advertise the returned list and pass the original application tool to `prepare_tool_call()`. Repeatedly preparing the original list preserves ownership. +Pass an existing transport or request session as `session_id`. A valid echoed +`conversation_id` takes precedence. Otherwise the existing session stays, and no new handle is +minted. `prepare_tool_result()` appends a new handle to the result's `content` and mirrors it +into `structuredContent` when the tool declares an output schema. If the result has no channel +that can carry a new handle, `conversation_id` is `None` and the derived `session_id` is kept. +Set `enable_conversation_id=False` on `PostHogMCP` to keep the previous custom-dispatcher +behavior. + ## Collect agent feedback Feedback collection is off by default. Enable it to advertise a `send_feedback` @@ -204,8 +218,14 @@ tools = posthog.prepare_tool_list(server_tools, collect_feedback=True) # tools/call dispatcher call = posthog.prepare_tool_call(tool_name, raw_args) if call.is_feedback: - posthog.capture_feedback(report=call.feedback_report) # emits $mcp_feedback - return send_feedback_result() # replies to the agent and stops dispatch + # Replies to the agent and stops dispatch. + prepared = posthog.prepare_tool_result(send_feedback_result(), call) + posthog.capture_feedback( # emits $mcp_feedback + report=call.feedback_report, + session_id=prepared.session_id, + conversation_id=prepared.conversation_id, + ) + return prepared.result ``` `on_feedback` is ignored on this path — the dispatcher routes reports itself via diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 298de241c..568f0653d 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -86,6 +86,7 @@ MCPAnalyticsModelSource, MCPAnalyticsOptions, PreparedToolCall, + PreparedToolResult, UserIdentity, ) from .version import __version__ @@ -103,6 +104,7 @@ "CollectFeedbackOptions", "FeedbackReport", "PreparedToolCall", + "PreparedToolResult", "get_more_tools_result", "send_feedback_result", "SEND_FEEDBACK_TOOL_NAME", diff --git a/posthog/mcp/_conversation_id.py b/posthog/mcp/_conversation_id.py index f6fcafb88..5b9c72d6e 100644 --- a/posthog/mcp/_conversation_id.py +++ b/posthog/mcp/_conversation_id.py @@ -64,6 +64,18 @@ def add_conversation_id_to_schema( return schema +def can_inject_conversation_id(input_schema: Any) -> bool: + """Whether the SDK can own ``conversation_id`` on this input schema. An + application-declared field or a composed schema stays the application's, + so its value is never read as a handle or stripped before dispatch.""" + if not isinstance(input_schema, dict): + return True + properties = input_schema.get("properties") + if isinstance(properties, dict) and CONVERSATION_ID_PARAM_NAME in properties: + return False + return not any(input_schema.get(key) for key in ("$ref", "oneOf", "allOf", "anyOf")) + + def extract_conversation_id(args: Any) -> Optional[str]: if not isinstance(args, dict): return None diff --git a/posthog/mcp/_output_instructions.py b/posthog/mcp/_output_instructions.py index d340f36ab..b93fe3426 100644 --- a/posthog/mcp/_output_instructions.py +++ b/posthog/mcp/_output_instructions.py @@ -123,8 +123,19 @@ def add_instructions_to_output_schema(tool: Any) -> bool: ) return False + try: + setattr(tool, attr, declare_output_instructions(original)) + except Exception: # noqa: BLE001 - some schema attrs may be read-only + log(f"WARN: could not set {attr} on tool {name}") + return False + return True + + +def declare_output_instructions(output_schema: Dict[str, Any]) -> Dict[str, Any]: + """A copy of ``output_schema`` with the optional :data:`MCP_INSTRUCTIONS_KEY` + declared. Callers check :func:`can_declare_output_instructions` first.""" # Deep copy: the server may reuse or freeze the schema object it handed us. - schema = copy.deepcopy(original) + schema = copy.deepcopy(output_schema) if not isinstance(schema.get("properties"), dict): schema["properties"] = {} schema["properties"][MCP_INSTRUCTIONS_KEY] = { @@ -137,12 +148,14 @@ def add_instructions_to_output_schema(tool: Any) -> bool: } }, } - try: - setattr(tool, attr, schema) - except Exception: # noqa: BLE001 - some schema attrs may be read-only - log(f"WARN: could not set {attr} on tool {name}") - return False - return True + return schema + + +def tool_output_schema(tool: Any) -> Any: + """A tool's advertised output schema, whether it is a dict or an SDK model.""" + if isinstance(tool, dict): + return tool.get("outputSchema") + return _read_attr(tool, _OUTPUT_SCHEMA_ATTRS)[1] def build_conversation_instructions(conversation_id: str) -> Dict[str, Any]: diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index c0fb8190c..4f115b698 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -13,6 +13,7 @@ import copy from datetime import datetime, timezone +from dataclasses import dataclass from typing import Any, Dict, List, Optional, Set, Tuple, Union from posthog.client import Client @@ -22,6 +23,13 @@ get_context_description, is_context_enabled, ) +from ._conversation_id import ( + add_conversation_id_to_schema, + build_prompt_back, + can_inject_conversation_id, + inject_prompt_back, + resolve_conversation_id, +) from ._event_types import MCPAnalyticsEventType from ._exceptions import capture_exception from ._instrumentation import ( @@ -42,6 +50,13 @@ normalize_model, resolve_model, ) +from ._output_instructions import ( + add_instructions_to_output_schema, + can_declare_output_instructions, + declare_output_instructions, + mirror_instructions_into_structured_content, + tool_output_schema, +) from ._sink import McpCaptureOptions, McpEventSink from .feedback import ( build_feedback_event_properties, @@ -51,6 +66,7 @@ resolve_collect_feedback_options, resolve_send_feedback_tool_name, ) +from .session import derive_session_id_from_conversation from .tools import build_report_missing_descriptor from .types import ( CollectFeedbackOptions, @@ -59,7 +75,10 @@ MCPAnalyticsContextOptions, MCPAnalyticsModelOptions, MCPAnalyticsModelSource, + PreparedConversationState, PreparedToolCall, + PreparedToolResult, + TResult, ) __all__ = ["PostHogMCP"] @@ -67,10 +86,23 @@ _GET_MORE_TOOLS_NAME = "get_more_tools" +@dataclass(frozen=True) +class _ConversationOwnership: + """Whether the SDK owns a tool's ``conversation_id`` input and can declare + ``_mcp_instructions`` on its output schema.""" + + conversation_id: bool + output_instructions: bool + + +_NOT_OWNED = _ConversationOwnership(conversation_id=False, output_instructions=False) + + class PostHogMCP(Client): """A drop-in posthog ``Client`` with ``capture_tool_call`` / ``capture_initialize`` / ``capture_tools_list`` / ``capture_missing_capability`` / ``capture_feedback`` - plus ``prepare_tool_list`` and ``prepare_tool_call`` helpers. ``capture``, + plus ``prepare_tool_list``, ``prepare_tool_call`` and ``prepare_tool_result`` + helpers. ``capture``, ``flush``, ``shutdown``, feature flags, etc. all work unchanged.""" def __init__( @@ -80,6 +112,7 @@ def __init__( mcp_exception_autocapture: bool = True, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False, + enable_conversation_id: bool = True, **kwargs: Any, ) -> None: super().__init__(api_key, **kwargs) @@ -113,6 +146,10 @@ def __init__( self._mcp_exception_autocapture = mcp_exception_autocapture self._capture_model = capture_model self._model_parameter_injected: Dict[str, bool] = {} + # Correlate calls through an agent-carried `conversation_id` and a derived + # session id. Off leaves schemas, arguments, results, and capture unchanged. + self._enable_conversation_id = enable_conversation_id + self._conversation_ownership: Dict[str, _ConversationOwnership] = {} # (kind, name) collision warnings already emitted from prepare_tool_list, # so a host that prepares a listing per request logs each once. self._warned_virtual_tool_collisions: Set[Tuple[str, str]] = set() @@ -152,6 +189,7 @@ def capture_tool_call( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, @@ -164,6 +202,7 @@ def capture_tool_call( MCPAnalyticsEventType.MCP_TOOLS_CALL, distinct_id, session_id, + conversation_id, set_properties, groups, properties, @@ -199,6 +238,7 @@ def capture_initialize( duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, @@ -211,6 +251,7 @@ def capture_initialize( MCPAnalyticsEventType.MCP_INITIALIZE, distinct_id, session_id, + conversation_id, set_properties, groups, properties, @@ -239,6 +280,7 @@ def capture_tools_list( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, @@ -252,6 +294,7 @@ def capture_tools_list( MCPAnalyticsEventType.MCP_TOOLS_LIST, distinct_id, session_id, + conversation_id, set_properties, groups, properties, @@ -282,6 +325,7 @@ def capture_missing_capability( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, @@ -290,11 +334,14 @@ def capture_missing_capability( timestamp: Optional[datetime] = None, ) -> None: """Capture a ``get_more_tools`` call as a missing-capability report. Emits - ``$mcp_missing_capability`` with the agent's description as ``$mcp_intent``.""" + ``$mcp_missing_capability`` with the agent's description as ``$mcp_intent``. + Reply to the agent with ``get_more_tools_result()`` after passing it + through :meth:`prepare_tool_result`.""" event = self._base_event( MCPAnalyticsEventType.MCP_MISSING_CAPABILITY, distinct_id, session_id, + conversation_id, set_properties, groups, properties, @@ -318,6 +365,7 @@ def capture_feedback( protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, + conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, @@ -329,11 +377,13 @@ def capture_feedback( ``$mcp_feedback`` with the report's ``$mcp_feedback_*`` properties and its summary/details as ``$mcp_intent``. Reply to the agent with ``send_feedback_result()`` (or a custom text) after routing the report to - your own feedback backend.""" + your own feedback backend and passing the reply through + :meth:`prepare_tool_result`.""" event = self._base_event( MCPAnalyticsEventType.MCP_FEEDBACK, distinct_id, session_id, + conversation_id, set_properties, groups, properties, @@ -369,9 +419,15 @@ def prepare_tool_list( ``get_more_tools`` virtual tool (``report_missing=True``) and the ``send_feedback`` virtual tool (``collect_feedback=True``, which also requires the constructor's ``collect_feedback`` option — the enable switch - that gates detection in :meth:`prepare_tool_call`). Returns a new list; + that gates detection in :meth:`prepare_tool_call`). By default it also + injects the optional ``conversation_id`` argument and declares + ``_mcp_instructions`` on compatible output schemas. Returns a new list; dict tools are copied, context injection mutates tool objects in place, - and model injection copies them to preserve field ownership. + and model and conversation injection copy them to preserve field + ownership. + + Pair it with :meth:`prepare_tool_call` on the inbound side and + :meth:`prepare_tool_result` on the outbound side. **On a paginated listing, pass the two switches for the first page only** — a client concatenates every page into one list:: @@ -426,7 +482,10 @@ def prepare_tool_list( ) elif existing is None: prepared.append(get_feedback_tool_descriptor(self._collect_feedback)) + # Read ownership before any analytics field lands on the schemas. + conversation_ownership = _collect_conversation_ownership(prepared) prepared = self._inject_models(prepared) + prepared = self._inject_conversation(prepared, conversation_ownership) return prepared def _warn_virtual_tool_collision( @@ -455,18 +514,42 @@ def prepare_tool_call( *, request_meta: Optional[JsonRecord] = None, original_tool: Any = None, + session_id: Optional[str] = None, ) -> PreparedToolCall: """Pull the agent's intent off the injected ``context`` argument, strip ``context`` from the arguments, and flag the ``get_more_tools`` and ``send_feedback`` virtual tools (the latter only with the constructor's ``collect_feedback`` opt-in, so a real tool by that name is never shadowed). When model capture is enabled, resolve its value and source and - strip the SDK-owned ``llm_model`` argument before dispatch. + strip the SDK-owned ``llm_model`` argument before dispatch. When + conversation correlation is enabled, validate an echoed + ``conversation_id`` or mint a new one, strip the SDK-owned argument, and + derive the session id from it. + + Dispatch the returned ``args`` to your tool. Then pass the tool result and + this prepared call to :meth:`prepare_tool_result`. Return its ``result`` + and capture with its ``session_id`` and ``conversation_id``:: + + call = posthog.prepare_tool_call(name, raw_args, original_tool=tool) + prepared = posthog.prepare_tool_result(run_tool(name, call.args), call) + posthog.capture_tool_call( + name, + intent=call.intent, + session_id=prepared.session_id, + conversation_id=prepared.conversation_id, + ) + return prepared.result ``original_tool`` is the application's own tool for ``name``, from the host's un-prepared list (the virtual tools never exist there). Passing it - also disambiguates a name collision: a real tool by the feedback tool's - name is dispatched normally instead of being flagged as feedback.""" + keeps ownership accurate when ``tools/list`` and ``tools/call`` reach + different replicas. It also disambiguates a name collision: a real tool by + the feedback tool's name is dispatched normally instead of being flagged + as feedback. + + ``session_id`` is a session carried by the request or transport. A valid + echoed ``conversation_id`` takes precedence. Otherwise the carried session + stays, and no new handle is minted.""" raw_context = (args or {}).get("context") intent = ( raw_context.strip() @@ -489,6 +572,28 @@ def prepare_tool_call( prepared_args = _strip_context(args) if analytics_owns_model: prepared_args = _strip_model(prepared_args) + + ownership = ( + _conversation_ownership_of(original_tool) + if original_tool is not None + else self._conversation_ownership.get(name) + ) + # Reads fail open on unknown ownership; strips need a positive answer. + can_read_conversation = self._enable_conversation_id and ( + ownership is None or ownership.conversation_id + ) + conversation_id, minted = resolve_conversation_id(can_read_conversation, args) + # A carried session stays stable until the agent echoes its own handle. + if minted and session_id: + conversation_id, minted = None, False + if conversation_id: + session_id = derive_session_id_from_conversation(conversation_id) + if ( + self._enable_conversation_id + and ownership is not None + and ownership.conversation_id + ): + prepared_args = _strip_conversation_id(prepared_args) # A supplied `original_tool` is a real application tool by this name (it # comes from the host's own list, which never holds a virtual tool), so # the real tool wins — the stateless twin of the ownership check @@ -522,6 +627,52 @@ def prepare_tool_call( if is_feedback else None ), + session_id=session_id, + conversation_id=conversation_id, + _conversation_state=PreparedConversationState( + minted=minted, + output_instructions=self._enable_conversation_id + and ownership is not None + and ownership.output_instructions, + ), + ) + + def prepare_tool_result( + self, result: TResult, prepared_call: PreparedToolCall + ) -> PreparedToolResult[TResult]: + """Add the conversation handle to a tool result without changing the + original value. A newly minted handle is appended to the text + ``content`` once. When the advertised output schema declares it, the + handle is also mirrored into ``structuredContent`` on every result. + + Return the prepared ``result`` to the client, and capture with its + ``session_id`` and ``conversation_id``. If a new handle could not reach + the client, ``conversation_id`` is omitted and the derived + ``session_id`` is kept.""" + conversation_id = prepared_call.conversation_id + session_id = prepared_call.session_id + state = prepared_call._conversation_state + if not conversation_id: + return PreparedToolResult(result, session_id, conversation_id) + if state is None: + # A call built outside prepare_tool_call: its delivery is unknown. + return PreparedToolResult(result, session_id, None) + + prepared: Any = result + delivered = False + if state.output_instructions: + prepared, delivered = mirror_instructions_into_structured_content( + prepared, conversation_id + ) + if state.minted: + injected = _inject_prompt_back(prepared, conversation_id) + if injected is not prepared: + delivered = True + prepared = injected + return PreparedToolResult( + prepared, + session_id, + None if state.minted and not delivered else conversation_id, ) # --- internals ----------------------------------------------------------- @@ -531,6 +682,7 @@ def _base_event( event_type: str, distinct_id: Optional[str], session_id: Optional[str], + conversation_id: Optional[str], set_properties: Optional[JsonRecord], groups: Optional[Dict[str, str]], properties: Optional[JsonRecord], @@ -541,6 +693,9 @@ def _base_event( event: Dict[str, Any] = { "event_type": event_type, "session_id": session_id, + # Pass the value from prepare_tool_result: it drops a newly minted + # handle that never reached the client. + "conversation_id": conversation_id, "timestamp": timestamp or datetime.now(timezone.utc), "properties": properties, "groups": groups, @@ -651,6 +806,24 @@ def _inject_model(self, tool: Any, ownership: Dict[str, bool]) -> Any: ownership[name] = False return tool + def _inject_conversation( + self, tools: List[Any], ownership: Dict[str, _ConversationOwnership] + ) -> List[Any]: + if not self._enable_conversation_id: + self._conversation_ownership = {} + return tools + prepared = [] + for tool in tools: + name = _tool_name(tool) + owned = ownership.get(name) if name is not None else None + if name is None or owned is None or owned == _NOT_OWNED: + prepared.append(tool) + continue + injected, ownership[name] = _inject_conversation_fields(tool, name, owned) + prepared.append(injected) + self._conversation_ownership = ownership + return prepared + def _apply_intent( event: Dict[str, Any], intent: Optional[str], source: Optional[str] @@ -686,6 +859,110 @@ def _strip_model(args: Optional[JsonRecord]) -> Optional[JsonRecord]: return {k: v for k, v in args.items() if k != "llm_model"} +def _strip_conversation_id(args: Optional[JsonRecord]) -> Optional[JsonRecord]: + if not args or "conversation_id" not in args: + return args + return {k: v for k, v in args.items() if k != "conversation_id"} + + +def _inject_conversation_fields( + tool: Any, name: str, owned: _ConversationOwnership +) -> Tuple[Any, _ConversationOwnership]: + """A copy of ``tool`` carrying the owned conversation fields, and the + ownership that actually landed. Read-only descriptors fail closed.""" + injected = _copy_tool(tool) + if injected is None: + return tool, _NOT_OWNED + try: + if owned.conversation_id: + _set_tool_schema( + injected, add_conversation_id_to_schema(_tool_schema(tool), name) + ) + except Exception: # noqa: BLE001 + return tool, _NOT_OWNED + if not owned.output_instructions: + return injected, owned + if isinstance(injected, dict): + injected["outputSchema"] = declare_output_instructions(injected["outputSchema"]) + return injected, owned + declared = add_instructions_to_output_schema(injected) + return injected, _ConversationOwnership(owned.conversation_id, declared) + + +def _conversation_ownership_of(tool: Any) -> _ConversationOwnership: + return _ConversationOwnership( + conversation_id=can_inject_conversation_id(_tool_schema(tool)), + output_instructions=can_declare_output_instructions(tool_output_schema(tool)), + ) + + +def _collect_conversation_ownership( + tools: List[Any], +) -> Dict[str, _ConversationOwnership]: + """Ownership per tool name. Two tools sharing a name fail closed: the SDK + cannot tell which one a call targets.""" + ownership: Dict[str, _ConversationOwnership] = {} + for tool in tools: + name = _tool_name(tool) + if name is None: + continue + found = _conversation_ownership_of(tool) + current = ownership.get(name) + if current is not None: + found = _ConversationOwnership( + current.conversation_id and found.conversation_id, + current.output_instructions and found.output_instructions, + ) + ownership[name] = found + return ownership + + +def _inject_prompt_back(result: Any, conversation_id: str) -> Any: + """Append the handle to a dict or ``CallToolResult`` result's ``content``, + including a ``CallToolResult`` inside an MCP SDK 1.x ``ServerResult``. + Returns ``result`` itself when there is no content list to append to.""" + if isinstance(result, dict): + return inject_prompt_back(result, conversation_id) + target = getattr(result, "root", result) + content = getattr(target, "content", None) + copy_model = getattr(target, "model_copy", None) + if not isinstance(content, list) or not callable(copy_model): + return result + # A model result means the MCP SDK is installed; it stays a peer dependency. + import mcp.types as mcp_types # noqa: PLC0415 + + block = mcp_types.TextContent( + type="text", text=build_prompt_back(conversation_id)["text"] + ) + try: + updated = copy_model(update={"content": [*content, block]}) + if target is result: + return updated + return result.model_copy(update={"root": updated}) + except Exception: # noqa: BLE001 - never let delivery break the tool path + return result + + +def _copy_tool(tool: Any) -> Optional[Any]: + """A shallow copy of ``tool``, or ``None`` when it cannot be copied.""" + if isinstance(tool, dict): + return dict(tool) + try: + copied = copy.copy(tool) + except Exception: # noqa: BLE001 + return None + return None if copied is tool else copied + + +def _set_tool_schema(tool: Any, schema: Any) -> None: + if isinstance(tool, dict): + tool["inputSchema"] = schema + elif hasattr(tool, "input_schema"): + tool.input_schema = schema + else: + tool.inputSchema = schema + + def _tool_description(tool: Any) -> Any: """A tool's description, whether it is a dict or an SDK model.""" if isinstance(tool, dict): diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index 6b8bfc444..801a7605c 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -22,10 +22,12 @@ Awaitable, Callable, Dict, + Generic, List, Literal, Optional, TypedDict, + TypeVar, Union, ) @@ -242,6 +244,40 @@ class PreparedToolCall: # ``PostHogMCP.capture_feedback`` and to your own feedback backend, then # reply with ``send_feedback_result()`` or a custom text. feedback_report: Optional[FeedbackReport] = None + # The resolved session id to use when capturing this call. + session_id: Optional[str] = None + # The resolved conversation handle. Capture the value from + # ``PostHogMCP.prepare_tool_result`` instead: a newly minted handle that + # could not reach the client is removed there. + conversation_id: Optional[str] = None + # Delivery state for ``prepare_tool_result``. Plain data, so a prepared call + # survives a copy or pickle across workers. + _conversation_state: Optional[PreparedConversationState] = field( + default=None, repr=False + ) + + +@dataclass(frozen=True) +class PreparedConversationState: + """How :meth:`PostHogMCP.prepare_tool_result` may deliver the handle.""" + + minted: bool = False + output_instructions: bool = False + + +TResult = TypeVar("TResult") + + +@dataclass +class PreparedToolResult(Generic[TResult]): + """Result of :meth:`PostHogMCP.prepare_tool_result`.""" + + # The result to return to the MCP client. + result: TResult + # The resolved session id to use when capturing this call. + session_id: Optional[str] = None + # The conversation handle to capture, set only if it reached the client. + conversation_id: Optional[str] = None @dataclass diff --git a/posthog/test/mcp/test_feedback.py b/posthog/test/mcp/test_feedback.py index b7583d3f9..1e14b0c28 100644 --- a/posthog/test/mcp/test_feedback.py +++ b/posthog/test/mcp/test_feedback.py @@ -938,7 +938,9 @@ async def test_posthogmcp_repreparing_a_prepared_list_is_not_a_collision(caplog) # Hosts may re-prepare an already-prepared list. PostHog's own descriptors # come back in it, and mistaking them for host tools would both warn about # ourselves and duplicate the tools. - client, _ = make_client(collect_feedback=True, capture_model=False) + client, _ = make_client( + collect_feedback=True, capture_model=False, enable_conversation_id=False + ) tools = [{"name": "search", "inputSchema": {"type": "object", "properties": {}}}] once = client.prepare_tool_list(tools, report_missing=True, collect_feedback=True) diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 9355e8106..f8fa0e25d 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -1,13 +1,22 @@ """Tests for the PostHogMCP custom-dispatcher client (Milestone 3).""" +import json +import pickle +import re from types import SimpleNamespace from unittest import mock import pytest -from mcp.types import Tool +from mcp.types import CallToolResult, ServerResult, TextContent, Tool from posthog.capture_mode import CaptureMode -from posthog.mcp import PostHogMCP +from posthog.mcp import ( + PostHogMCP, + PreparedToolCall, + derive_session_id_from_conversation, + get_more_tools_result, +) +from posthog.mcp._output_instructions import MCP_INSTRUCTIONS_KEY from posthog.test.mcp._helpers import ( events_named as _events, flush_background as _flush, @@ -329,3 +338,283 @@ def test_prepare_tool_list_fails_closed_for_duplicate_tool_names(): call = client.prepare_tool_call("route", {"llm_model": "application-owned"}) assert call.args == {"llm_model": "application-owned"} assert call.llm_model is None + + +_CONVERSATION_ID = "0198ef20-1234-7abc-8def-123456789abc" +_UUID7 = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + + +def _sql_tool() -> dict: + return { + "name": "execute-sql", + "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}}, + "outputSchema": { + "type": "object", + "properties": {"rows": {"type": "array"}}, + "additionalProperties": False, + }, + } + + +def _handle_block(conversation_id: str) -> dict: + return {"type": "text", "text": json.dumps({"conversation_id": conversation_id})} + + +def test_prepare_tool_list_adds_conversation_schemas_without_mutating_source(): + client, _ = make_client() + tools = [_sql_tool()] + + prepared = client.prepare_tool_list(tools) + + input_schema = prepared[0]["inputSchema"] + assert input_schema["properties"]["conversation_id"]["type"] == "string" + assert "conversation_id" not in input_schema.get("required", []) + assert prepared[0]["outputSchema"]["properties"][MCP_INSTRUCTIONS_KEY]["type"] == ( + "object" + ) + assert tools == [_sql_tool()] + + +def test_conversation_disabled_leaves_schemas_arguments_and_results_unchanged(): + client, _ = make_client(enable_conversation_id=False) + prepared_tools = client.prepare_tool_list([_sql_tool()]) + assert "conversation_id" not in prepared_tools[0]["inputSchema"]["properties"] + assert MCP_INSTRUCTIONS_KEY not in prepared_tools[0]["outputSchema"]["properties"] + + raw_args = {"query": "select 1", "conversation_id": _CONVERSATION_ID} + call = client.prepare_tool_call("execute-sql", raw_args) + tool_result = {"content": [], "structuredContent": {"rows": []}} + prepared = client.prepare_tool_result(tool_result, call) + + assert call.args == raw_args + assert (call.session_id, call.conversation_id) == (None, None) + assert prepared.result is tool_result + assert (prepared.session_id, prepared.conversation_id) == (None, None) + + +def test_conversation_preserves_application_fields_and_fails_closed_for_duplicates(): + client, _ = make_client() + application_owned = { + "name": "execute-sql", + "inputSchema": { + "type": "object", + "properties": {"conversation_id": {"type": "string"}}, + }, + "outputSchema": { + "type": "object", + "properties": {MCP_INSTRUCTIONS_KEY: {"type": "string"}}, + }, + } + + prepared = client.prepare_tool_list([_sql_tool(), application_owned]) + + assert "conversation_id" not in prepared[0]["inputSchema"]["properties"] + assert MCP_INSTRUCTIONS_KEY not in prepared[0]["outputSchema"]["properties"] + assert prepared[1]["inputSchema"]["properties"]["conversation_id"] == { + "type": "string" + } + assert prepared[1]["outputSchema"]["properties"][MCP_INSTRUCTIONS_KEY] == { + "type": "string" + } + call = client.prepare_tool_call( + "execute-sql", {"conversation_id": _CONVERSATION_ID} + ) + assert call.args == {"conversation_id": _CONVERSATION_ID} + assert call.conversation_id is None + + +def test_prepare_tool_call_uses_original_tool_without_prior_listing(): + client, _ = make_client() + call = client.prepare_tool_call( + "execute-sql", + {"query": "select 1", "conversation_id": _CONVERSATION_ID}, + original_tool=_sql_tool(), + ) + + assert call.args == {"query": "select 1"} + assert call.conversation_id == _CONVERSATION_ID + assert call.session_id == derive_session_id_from_conversation(_CONVERSATION_ID) + + +def test_prepare_tool_call_mints_handles_and_derives_stable_sessions_across_clients(): + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + minted = client.prepare_tool_call( + "execute-sql", {"query": "select 1", "conversation_id": "invalid"} + ) + assert _UUID7.match(minted.conversation_id) + assert minted.args == {"query": "select 1"} + assert minted.session_id == derive_session_id_from_conversation( + minted.conversation_id + ) + + other_replica, _ = make_client() + first = client.prepare_tool_call( + "execute-sql", {"conversation_id": _CONVERSATION_ID} + ) + echoed = other_replica.prepare_tool_call( + "execute-sql", + {"conversation_id": _CONVERSATION_ID.upper()}, + original_tool=_sql_tool(), + ) + assert echoed.conversation_id == _CONVERSATION_ID + assert echoed.session_id == first.session_id + + +def test_prepare_tool_call_keeps_carried_session_unless_a_handle_is_echoed(): + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + + carried = client.prepare_tool_call("execute-sql", {}, session_id="ses_carried") + assert (carried.session_id, carried.conversation_id) == ("ses_carried", None) + + echoed = client.prepare_tool_call( + "execute-sql", + {"conversation_id": _CONVERSATION_ID}, + session_id="ses_carried", + ) + assert echoed.conversation_id == _CONVERSATION_ID + assert echoed.session_id == derive_session_id_from_conversation(_CONVERSATION_ID) + + +@pytest.mark.parametrize( + "transport", + [lambda call: call, lambda call: pickle.loads(pickle.dumps(call))], + ids=["same-process", "pickled"], +) +def test_prepare_tool_result_delivers_minted_handle_without_mutation(transport): + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + call = client.prepare_tool_call("execute-sql", {"query": "select 1"}) + tool_result = { + "content": [{"type": "text", "text": "done"}], + "structuredContent": {"rows": []}, + } + + prepared = client.prepare_tool_result(tool_result, transport(call)) + + assert tool_result == { + "content": [{"type": "text", "text": "done"}], + "structuredContent": {"rows": []}, + } + assert prepared.result["content"][-1] == _handle_block(call.conversation_id) + assert prepared.result["structuredContent"][MCP_INSTRUCTIONS_KEY] == { + "conversation_id": call.conversation_id + } + assert prepared.conversation_id == call.conversation_id + + +@pytest.mark.parametrize("wrapped", [False, True], ids=["bare", "server-result"]) +def test_prepare_tool_result_delivers_into_call_tool_result_models(wrapped): + if wrapped and not isinstance(ServerResult, type): + pytest.skip("MCP SDK 2.x has no ServerResult wrapper") + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + call = client.prepare_tool_call("execute-sql", {}) + call_result = CallToolResult( + content=[TextContent(type="text", text="done")], + structuredContent={"rows": []}, + ) + tool_result = ServerResult(call_result) if wrapped else call_result + + prepared = client.prepare_tool_result(tool_result, call) + + delivered = prepared.result.root if wrapped else prepared.result + assert len(call_result.content) == 1 + assert delivered.content[-1].text == _handle_block(call.conversation_id)["text"] + assert delivered.structuredContent[MCP_INSTRUCTIONS_KEY] == { + "conversation_id": call.conversation_id + } + assert prepared.conversation_id == call.conversation_id + + +def test_prepare_tool_result_omits_conversation_without_delivery_state(): + client, _ = make_client() + tool_result = {"content": []} + call = PreparedToolCall(session_id="ses_123", conversation_id=_CONVERSATION_ID) + + prepared = client.prepare_tool_result(tool_result, call) + + assert prepared.result is tool_result + assert (prepared.session_id, prepared.conversation_id) == ("ses_123", None) + + +def test_prepare_tool_result_preserves_application_structured_instructions(): + client, _ = make_client() + tool = { + **_sql_tool(), + "outputSchema": { + "type": "object", + "properties": {MCP_INSTRUCTIONS_KEY: {"type": "string"}}, + }, + } + client.prepare_tool_list([tool]) + call = client.prepare_tool_call("execute-sql", {}) + + prepared = client.prepare_tool_result( + {"content": [], "structuredContent": {MCP_INSTRUCTIONS_KEY: "app-value"}}, + call, + ) + + assert prepared.result["structuredContent"][MCP_INSTRUCTIONS_KEY] == "app-value" + assert prepared.conversation_id == call.conversation_id + + +def test_prepare_tool_result_delivers_minted_handle_on_error_results(): + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + call = client.prepare_tool_call("execute-sql", {}) + + prepared = client.prepare_tool_result({"content": [], "isError": True}, call) + + assert prepared.result["isError"] is True + assert _handle_block(call.conversation_id) in prepared.result["content"] + assert prepared.conversation_id == call.conversation_id + + +def test_prepare_tool_result_omits_undelivered_minted_handle_but_keeps_session(): + client, _ = make_client() + client.prepare_tool_list([_sql_tool()]) + call = client.prepare_tool_call("execute-sql", {}) + tool_result = {"value": 1} + + prepared = client.prepare_tool_result(tool_result, call) + + assert prepared.result is tool_result + assert prepared.conversation_id is None + assert prepared.session_id == call.session_id + + +def test_prepare_tool_list_adds_conversation_to_virtual_tools(): + client, _ = make_client() + prepared = client.prepare_tool_list([], report_missing=True) + virtual_tool = next(t for t in prepared if t["name"] == "get_more_tools") + call = client.prepare_tool_call("get_more_tools", {"context": "Find a tool"}) + + result = client.prepare_tool_result(get_more_tools_result(), call) + + assert virtual_tool["inputSchema"]["properties"]["conversation_id"]["type"] == ( + "string" + ) + assert result.result["content"][-1] == _handle_block(call.conversation_id) + + +async def test_capture_tool_call_records_prepared_conversation_and_session(): + client, captured = make_client() + client.prepare_tool_list([_sql_tool()]) + call = client.prepare_tool_call("execute-sql", {}) + prepared = client.prepare_tool_result({"content": []}, call) + + client.capture_tool_call( + "execute-sql", + distinct_id="user-123", + session_id=prepared.session_id, + conversation_id=prepared.conversation_id, + ) + await _flush() + + props = _events(captured, "$mcp_tool_call")[0]["properties"] + assert props["$mcp_conversation_id"] == prepared.conversation_id + assert props["$session_id"] == prepared.session_id diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 95783ef4c..51ccbd8d0 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -350,6 +350,7 @@ alias posthog.mcp.PostHogMCPAnalyticsEvent -> posthog.mcp.constants.PostHogMCPAn alias posthog.mcp.PostHogMCPAnalyticsProperty -> posthog.mcp.constants.PostHogMCPAnalyticsProperty alias posthog.mcp.PostHogMcpStatelessSessionMiddleware -> posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware alias posthog.mcp.PreparedToolCall -> posthog.mcp.types.PreparedToolCall +alias posthog.mcp.PreparedToolResult -> posthog.mcp.types.PreparedToolResult alias posthog.mcp.SEND_FEEDBACK_TOOL_NAME -> posthog.mcp.feedback.SEND_FEEDBACK_TOOL_NAME alias posthog.mcp.SessionTokenPayload -> posthog.mcp.session_token.SessionTokenPayload alias posthog.mcp.UserIdentity -> posthog.mcp.types.UserIdentity @@ -845,6 +846,7 @@ attribute posthog.mcp.types.MCPAnalyticsOptions.logger: Optional[LoggerFn] = Non attribute posthog.mcp.types.MCPAnalyticsOptions.missing_capability_tool_name: Optional[str] = None attribute posthog.mcp.types.MCPAnalyticsOptions.report_missing: bool = False attribute posthog.mcp.types.PreparedToolCall.args: Optional[JsonRecord] = None +attribute posthog.mcp.types.PreparedToolCall.conversation_id: Optional[str] = None attribute posthog.mcp.types.PreparedToolCall.feedback_report: Optional[FeedbackReport] = None attribute posthog.mcp.types.PreparedToolCall.intent: Optional[str] = None attribute posthog.mcp.types.PreparedToolCall.intent_source: Optional[str] = None @@ -852,6 +854,7 @@ attribute posthog.mcp.types.PreparedToolCall.is_feedback: bool = False attribute posthog.mcp.types.PreparedToolCall.is_missing_capability: bool = False attribute posthog.mcp.types.PreparedToolCall.llm_model: Optional[str] = None attribute posthog.mcp.types.PreparedToolCall.llm_model_source: Optional[MCPAnalyticsModelSource] = None +attribute posthog.mcp.types.PreparedToolCall.session_id: Optional[str] = None attribute posthog.mcp.types.UserIdentity.distinct_id: str attribute posthog.mcp.types.UserIdentity.groups: Optional[Dict[str, str]] = None attribute posthog.mcp.types.UserIdentity.properties: Optional[JsonRecord] = None @@ -1044,7 +1047,7 @@ 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] = True, 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, enable_conversation_id: bool = True, **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) @@ -1052,7 +1055,7 @@ class posthog.mcp.types.FeedbackReport(feedback_type: str = 'other', summary: st 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) -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.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.UserIdentity(distinct_id: str, properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None) class posthog.metrics_capture.PostHogMetrics(client, config: Optional[dict] = None) class posthog.poller.Poller(interval, execute, *args, **kwargs) @@ -1476,14 +1479,15 @@ method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception) method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_feedback(*, report: FeedbackReport, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_initialize(*, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_missing_capability(*, context: Optional[str] = None, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, parameters: Any = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_tool_call(tool_name: str, *, intent: Optional[str] = None, intent_source: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, category: Optional[str] = None, tool_description: Optional[str] = None, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.capture_tools_list(*, tool_names: Optional[List[str]] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_feedback(*, report: FeedbackReport, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_initialize(*, client_name: Optional[str] = None, client_version: Optional[str] = None, protocol_version: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_missing_capability(*, context: Optional[str] = None, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, parameters: Any = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_tool_call(tool_name: str, *, intent: Optional[str] = None, intent_source: Optional[str] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, category: Optional[str] = None, tool_description: Optional[str] = None, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None +method posthog.mcp.posthog_mcp.PostHogMCP.capture_tools_list(*, tool_names: Optional[List[str]] = None, parameters: Any = None, response: Any = None, duration_ms: Optional[float] = None, is_error: bool = False, error: Any = None, error_type: Optional[str] = None, protocol_version: Optional[str] = None, distinct_id: Optional[str] = None, session_id: Optional[str] = None, conversation_id: Optional[str] = None, client_user_agent: Optional[str] = None, vendor_client: Optional[str] = None, set_properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None, properties: Optional[JsonRecord] = None, timestamp: Optional[datetime] = None) -> None method posthog.mcp.posthog_mcp.PostHogMCP.flush(timeout_seconds: Optional[float] = 10) -> None -method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_call(name: str, args: Optional[JsonRecord] = None, *, request_meta: Optional[JsonRecord] = None, original_tool: Any = None) -> PreparedToolCall +method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_call(name: str, args: Optional[JsonRecord] = None, *, request_meta: Optional[JsonRecord] = None, original_tool: Any = None, session_id: Optional[str] = None) -> PreparedToolCall method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_list(tools: List[Any], context: Union[bool, MCPAnalyticsContextOptions] = True, report_missing: bool = False, collect_feedback: bool = False) -> List[Any] +method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_result(result: TResult, prepared_call: PreparedToolCall) -> PreparedToolResult[TResult] method posthog.mcp.posthog_mcp.PostHogMCP.shutdown() -> None method posthog.metrics_capture.PostHogMetrics.count(name: str, value: float = 1, unit: Optional[str] = None, attributes: Optional[dict] = None) -> None method posthog.metrics_capture.PostHogMetrics.flush() -> None