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

Capture MCP resource discovery and reads from instrumented servers.
10 changes: 9 additions & 1 deletion posthog/mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# PostHog MCP analytics

Product analytics for Model Context Protocol servers. Wrap a Python MCP server so
every tool call, agent intent, and failure is captured to PostHog as a `$mcp_*` event.
tool calls, agent intent, resource discovery and reads, and failures are captured
to PostHog as `$mcp_*` events.

Resource bodies are not captured. Captured URLs redact usernames, passwords, and
known credential query parameters, including signed URL credentials. This also
applies when a failed read repeats the URL in its error message. Other query
parameters and fragments can still contain application-specific sensitive data.
Requests and responses keep their original addresses. Use `before_send` to remove
any additional application-specific sensitive data.

```python
from posthog import Posthog
Expand Down
14 changes: 7 additions & 7 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

"""PostHog MCP analytics SDK — product analytics for Model Context Protocol servers.

Wrap a Python MCP server so every tool call, agent intent, and failure is
captured to PostHog as a ``$mcp_*`` event. Works with the MCP Python SDK 1.x
*and* 2.x (the 2026-07-28 spec revision) — the high-level server class moved
between majors, but ``instrument()`` is the same::
Wrap a Python MCP server so tool calls, agent intent, resource discovery and
reads, and failures are captured to PostHog as ``$mcp_*`` events. Works with
the MCP Python SDK 1.x *and* 2.x (the 2026-07-28 spec revision) — the high-level
server class moved between majors, but ``instrument()`` is the same::

from posthog import Posthog
from posthog.mcp import instrument
Expand Down Expand Up @@ -215,9 +215,9 @@ def instrument(
posthog_client: Optional[Client] = None,
options: Optional[MCPAnalyticsOptions] = None,
) -> McpAnalytics:
"""Instrument an MCP server so PostHog auto-captures tool calls, tool listings,
initialize, identity, and exceptions. Returns a handle whose ``capture()``
records custom events.
"""Instrument an MCP server so PostHog auto-captures tool calls, tool and
resource listings, resource reads, initialize, identity, and exceptions.
Returns a handle whose ``capture()`` records custom events.

Idempotent per server instance — a second call reuses the existing tracking
state instead of double-wrapping. Degrades to a no-op handle on any failure so
Expand Down
4 changes: 4 additions & 0 deletions posthog/mcp/_instrument_fastmcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import mcp.types as mcp_types

from ._conversation_id import build_prompt_back
from ._instrument_lowlevel import _wrap_resource_requests
from ._instrumentation import (
_to_jsonable,
append_get_more_tools,
Expand All @@ -53,6 +54,9 @@ def instrument_fastmcp(server: Any, data: MCPAnalyticsData) -> None:
data.server_version = getattr(getattr(server, "_mcp_server", None), "version", None)
_wrap_tool_manager_call(server, data)
_wrap_list_tools_handler(server, data)
low_level = getattr(server, "_mcp_server", None)
if low_level is not None:
_wrap_resource_requests(low_level, data)


# --- tool call seam ----------------------------------------------------------
Expand Down
85 changes: 85 additions & 0 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@

from ._context_parameters import schema_has_param
from ._conversation_id import build_prompt_back
from ._event_types import MCPAnalyticsEventType
from ._instrumentation import (
_to_jsonable,
append_get_more_tools,
collect_listed_tools,
extract_tools,
mutate_tool_schema,
prepare_request,
record_resource_request,
request_to_dict,
resolve_session_and_client,
start_tool_call_lifecycle,
Expand All @@ -49,6 +52,7 @@ def instrument_low_level(server: Any, data: MCPAnalyticsData) -> None:
data.server_version = getattr(server, "version", None)
_wrap_call_tool(server, data, strip_injected=False)
_wrap_list_tools(server, data, context_required=False)
_wrap_resource_requests(server, data)


def instrument_fastmcp_v2(server: Any, data: MCPAnalyticsData) -> None:
Expand All @@ -72,6 +76,87 @@ def instrument_fastmcp_v2(server: Any, data: MCPAnalyticsData) -> None:
# sees: under `FastMCP(strict_input_validation=True)` every call fails with
# "'context' is a required property".
_wrap_list_tools(low_level, data, context_required=False)
_wrap_resource_requests(low_level, data)


def _wrap_resource_requests(server: Any, data: MCPAnalyticsData) -> None:
for request_type, event_type in (
(mcp_types.ListResourcesRequest, MCPAnalyticsEventType.MCP_RESOURCES_LIST),
(mcp_types.ReadResourceRequest, MCPAnalyticsEventType.MCP_RESOURCES_READ),
):
_wrap_resource_request(server, data, request_type, event_type)


def _wrap_resource_request(
server: Any,
data: MCPAnalyticsData,
request_type: Any,
event_type: str,
) -> None:
handlers = server.request_handlers
original = handlers.get(request_type)
if original is None or getattr(original, _WRAPPED_FLAG, False):
return

async def handler(req: Any) -> Any:
client_name, client_version = _client_info(server)
protocol_version = _protocol_version(server)
mcp_session_id = _mcp_session_id(server)
token, client_name, client_version, protocol_version = (
resolve_session_and_client(
mcp_session_id, client_name, client_version, protocol_version
)
)
request = request_to_dict(req)
extra = {"session_id": mcp_session_id, "ctx": _request_context(server)}
try:
session_id = await prepare_request(
data,
mcp_session_id=mcp_session_id,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
request=request,
extra=extra,
token=token,
)
except Exception as error: # noqa: BLE001 - analytics must not break resources
log(f"Warning: could not prepare resource analytics: {error}")
return await original(req)

start = time.monotonic()
try:
result = await original(req)
except Exception as error:
await record_resource_request(
data,
session_id,
event_type=event_type,
request=request,
error=error,
duration_ms=(time.monotonic() - start) * 1000,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
extra=extra,
)
raise

await record_resource_request(
data,
session_id,
event_type=event_type,
request=request,
duration_ms=(time.monotonic() - start) * 1000,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
extra=extra,
)
return result

setattr(handler, _WRAPPED_FLAG, True)
handlers[request_type] = handler


def _wrap_call_tool(
Expand Down
77 changes: 77 additions & 0 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@

from ._context_parameters import schema_has_param
from ._conversation_id import build_prompt_back
from ._event_types import MCPAnalyticsEventType
from ._instrumentation import (
_to_jsonable,
collect_listed_tools,
mutate_tool_schema,
params_to_request_dict,
prepare_request,
record_resource_request,
resolve_session_and_client,
start_tool_call_lifecycle,
start_tools_list_lifecycle,
Expand All @@ -63,6 +66,10 @@
# injected `context` parameter per entry point (see _wrap_v2_list_tools).
_CALL_METHOD = "tools/call"
_LIST_METHOD = "tools/list"
_RESOURCE_METHODS = {
"resources/list": MCPAnalyticsEventType.MCP_RESOURCES_LIST,
"resources/read": MCPAnalyticsEventType.MCP_RESOURCES_READ,
}


def instrument_mcpserver_v2(server: Any, data: MCPAnalyticsData) -> None:
Expand All @@ -81,6 +88,8 @@ def instrument_mcpserver_v2(server: Any, data: MCPAnalyticsData) -> None:
)
_wrap_tool_manager_call_v2(server, data)
_wrap_v2_list_tools(low_level, data, context_required=True, high_level=server)
for method, event_type in _RESOURCE_METHODS.items():
_wrap_v2_resource_request(low_level, data, method, event_type)
_patch_add_request_handler(low_level, data, wrap_call=False, high_level=server)


Expand All @@ -93,6 +102,8 @@ def instrument_lowlevel_v2(server: Any, data: MCPAnalyticsData) -> None:
data.server_version = getattr(server, "version", None)
_wrap_v2_call_tool(server, data)
_wrap_v2_list_tools(server, data, context_required=False)
for method, event_type in _RESOURCE_METHODS.items():
_wrap_v2_resource_request(server, data, method, event_type)
_patch_add_request_handler(server, data, wrap_call=True)


Expand Down Expand Up @@ -127,6 +138,8 @@ def add_request_handler(method: str, params_type: Any, handler: Any) -> None:
context_required=high_level is not None,
high_level=high_level,
)
elif method in _RESOURCE_METHODS:
_wrap_v2_resource_request(server, data, method, _RESOURCE_METHODS[method])

setattr(add_request_handler, _WRAPPED_FLAG, True)
server.add_request_handler = add_request_handler
Expand Down Expand Up @@ -440,6 +453,70 @@ async def handler(ctx: Any, params: Any) -> Any:
_replace_handler(server, _CALL_METHOD, handler, entry.params_type)


def _wrap_v2_resource_request(
server: Any, data: MCPAnalyticsData, method: str, event_type: str
) -> None:
entry = server.get_request_handler(method)
if entry is None or getattr(entry.handler, _WRAPPED_FLAG, False):
return
original = entry.handler

async def handler(ctx: Any, params: Any) -> Any:
token, client_name, client_version, protocol_version, mcp_session_id = (
_resolve_ctx(ctx)
)
request = params_to_request_dict(method, params, by_alias=True)
extra: Dict[str, Any] = {"session_id": mcp_session_id, "ctx": ctx}
try:
session_id = await prepare_request(
data,
mcp_session_id=mcp_session_id,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
request=request,
extra=extra,
token=token,
)
except Exception as error: # noqa: BLE001 - analytics must not break resources
log(f"Warning: could not prepare resource analytics: {error}")
return await original(ctx, params)

start = time.monotonic()
try:
result = await original(ctx, params)
except Exception as error:
await record_resource_request(
data,
session_id,
event_type=event_type,
request=request,
error=error,
duration_ms=(time.monotonic() - start) * 1000,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
extra=extra,
)
raise

await record_resource_request(
data,
session_id,
event_type=event_type,
request=request,
duration_ms=(time.monotonic() - start) * 1000,
client_name=client_name,
client_version=client_version,
protocol_version=protocol_version,
extra=extra,
)
return result

setattr(handler, _WRAPPED_FLAG, True)
_replace_handler(server, method, handler, entry.params_type)


# --- tools/list -------------------------------------------------------------------


Expand Down
47 changes: 43 additions & 4 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
# Copyright (c) 2025 MCPcat
# Licensed under the MIT License: https://github.com/MCPCat/mcpcat-typescript-sdk/blob/main/LICENSE

"""Shared tool-call / tools-list / initialize lifecycle used by both the FastMCP
and low-level server adapters. The adapters resolve transport-specific details
(client info, session id, raw result shape) and delegate the analytics flow here
so both stay in sync."""
"""Shared MCP request lifecycles used by both the FastMCP and low-level server
adapters. The adapters resolve transport-specific details (client info, session
id, raw result shape) and delegate analytics policy here so both stay in sync."""

from __future__ import annotations

Expand Down Expand Up @@ -834,3 +833,43 @@ async def record_tools_list(
fire_and_forget(capture_event(data, event), data)
except Exception as err: # noqa: BLE001 - isolate analytics from the tool path
log(f"record_tools_list failed (event dropped): {err}")


async def record_resource_request(
data: MCPAnalyticsData,
session_id: str,
*,
event_type: str,
request: Dict[str, Any],
error: Any = None,
duration_ms: Optional[float] = None,
client_name: Optional[str] = None,
client_version: Optional[str] = None,
protocol_version: Optional[str] = None,
extra: Optional[Dict[str, Any]] = None,
) -> None:
"""Record resources/list or resources/read without affecting dispatch."""
try:
params = request.get("params")
uri = params.get("uri") if isinstance(params, dict) else None
event: Dict[str, Any] = {
"event_type": event_type,
"session_id": session_id,
"resource_name": uri
if event_type == MCPAnalyticsEventType.MCP_RESOURCES_READ
else None,
"parameters": build_captured_mcp_parameters(request),
"duration": duration_ms,
"client_name": client_name,
"client_version": client_version,
"protocol_version": protocol_version,
"is_error": error is not None,
"timestamp": datetime.now(timezone.utc),
}
if error is not None:
event["error"] = capture_exception(error)
await _apply_event_properties(data, event, request, extra)
stamp_transport_identity(event, extra)
fire_and_forget(capture_event(data, event), data)
except Exception as err: # noqa: BLE001 - isolate analytics from the request path
log(f"record_resource_request failed (event dropped): {err}")
Loading