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-analytics-defaults.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Enable MCP model capture and conversation correlation by default, including on fresh low-level servers.
27 changes: 23 additions & 4 deletions posthog/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,31 @@ 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

analytics = instrument(
server,
posthog,
MCPAnalyticsOptions(capture_model=True),
)
```

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
37 changes: 33 additions & 4 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
55 changes: 40 additions & 15 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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)
)
Expand Down Expand Up @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions posthog/mcp/_tool_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""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:
return await asyncio.wait_for(
_find_model_ownership(name, list_page), timeout=0.25
)
Comment on lines +23 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ownership lookup is not cached

The fallback returns the resolved ownership without storing it. On a cold low-level server, each direct tool call therefore invokes the original tools/list handler again, potentially traversing 16 pages, repeating handler side effects, and adding up to 250 ms of latency. Cache the result in data.tool_model_parameter_injected before returning it.

Suggested change
return await asyncio.wait_for(
_find_model_ownership(name, list_page), timeout=0.25
)
owns_model = await asyncio.wait_for(
_find_model_ownership(name, list_page), timeout=0.25
)
data.tool_model_parameter_injected[name] = owns_model
return owns_model

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/mcp/_tool_schema.py
Line: 23-25

Comment:
**Ownership lookup is not cached**

The fallback returns the resolved ownership without storing it. On a cold low-level server, each direct tool call therefore invokes the original `tools/list` handler again, potentially traversing 16 pages, repeating handler side effects, and adding up to 250 ms of latency. Cache the result in `data.tool_model_parameter_injected` before returning it.

```suggestion
        owns_model = await asyncio.wait_for(
            _find_model_ownership(name, list_page), timeout=0.25
        )
        data.tool_model_parameter_injected[name] = owns_model
        return owns_model
```

**Knowledge Base Used:**
- [MCP analytics instrumentation](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-python/-/docs/mcp-analytics-instrumentation.md)
- [MCP framework instrumentation](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-python/-/docs/mcp-framework-instrumentation.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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]]
) -> 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 False
seen.add(cursor)
return False


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
2 changes: 1 addition & 1 deletion posthog/mcp/posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions posthog/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading