Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b245a3c
feat(mcp): capture resource discovery and reads
lucasheriques Sep 7, 2026
a341587
fix(mcp): keep resource bodies out of analytics
lucasheriques Sep 7, 2026
62caaf1
fix(mcp): redact credentials in captured resource addresses
lucasheriques Sep 8, 2026
6dea4de
fix(mcp): redact credentials embedded in captured URLs
lucasheriques Sep 8, 2026
88ebc29
Merge main into MCP resource tracking
lucasheriques Sep 9, 2026
0eb054c
fix(mcp): bound captured URL parsing
lucasheriques Sep 9, 2026
df1c814
fix(mcp): widen URL redaction, capture resource listings and template…
lucasheriques Sep 10, 2026
21f9f55
fix(mcp): strip trailing URL punctuation in linear time
lucasheriques Sep 10, 2026
e5892c9
fix(mcp): keep apostrophes inside captured URLs
lucasheriques Sep 10, 2026
2b11efc
fix(mcp): close three URL-redaction leaks
lucasheriques Sep 10, 2026
1d37981
fix(mcp): strip intent PII before the generic pass rewrites URLs
lucasheriques Sep 10, 2026
65ccc76
fix(mcp): keep tool names out of the entropy detector
lucasheriques Sep 10, 2026
5f5a65e
fix(mcp): redact credentials in resource uris with no authority
lucasheriques Sep 10, 2026
36e175e
fix(mcp): keep the URL length bound off data uris
lucasheriques Sep 10, 2026
98abc7b
fix(mcp): start the address at the authority, not at a prose word
lucasheriques Sep 10, 2026
b50cd56
fix(mcp): only skip a prefix that is really prose
lucasheriques Sep 10, 2026
1999e40
fix(mcp): split a match that runs two addresses together
lucasheriques Sep 10, 2026
f0c6c57
fix(mcp): run the credential detectors before the URL pass
lucasheriques Sep 10, 2026
3419fe3
fix(mcp): redact credentials in hash-routed fragments
lucasheriques Sep 10, 2026
757aa3c
fix(mcp): only treat a leading fragment segment as a route
lucasheriques Sep 10, 2026
e9e82dd
fix(mcp): gate binary intents before redacting their PII
lucasheriques Sep 10, 2026
916eadf
fix(mcp): sanitize an address carried in a plain fragment
lucasheriques Sep 10, 2026
19a696c
fix(mcp): report what a failing resource read actually raised
lucasheriques Sep 10, 2026
6c980c6
fix(mcp): sanitize route prefixes and bound both URL recursions
lucasheriques Sep 10, 2026
84655d0
fix(mcp): recognize a hash route that carries its own `=`
lucasheriques Sep 10, 2026
67a76c6
fix(mcp): split an adjacent address wherever it sits
lucasheriques Sep 10, 2026
2502fb8
fix(mcp): judge each half of a fragment on its own
lucasheriques Sep 10, 2026
d867631
fix(mcp): decide value position by the nearest structural character
lucasheriques Sep 10, 2026
b7179b9
fix(mcp): bound value position to the fields region, fail closed on a…
lucasheriques Sep 10, 2026
d3ec6c8
fix(mcp): find value position in one forward pass
lucasheriques Sep 10, 2026
b760dc6
fix(mcp): tell a URL's delimiters from the same characters inside a v…
lucasheriques Sep 10, 2026
7371ec7
fix(mcp): stop splitting a credential at a semicolon
lucasheriques Sep 10, 2026
d05b16e
fix(mcp): redact an intent's credentials before its PII
lucasheriques Sep 10, 2026
f70061d
fix(mcp): keep a credential whole across `;` and a fragment's `?`
lucasheriques Sep 10, 2026
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. URL credential redaction (userinfo, credential-named query and fragment parameters) now applies to every captured string, including existing `$mcp_tool_call` parameters, responses and error messages, so URLs in existing tool-call data will show `%5Bredacted%5D` values after upgrading.
17 changes: 16 additions & 1 deletion posthog/mcp/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
# 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. Resource and resource-template listings are:
a listing is metadata (names, uris, mime types), so `$mcp_resources_list` carries
it as `$mcp_response`.

Captured URLs redact usernames, passwords, and credential-named query and
fragment parameters, including signed URL credentials. This applies to every
captured string, tool call parameters, responses and error messages included, so
it also covers a failed read that repeats the URL in its error message. URLs
longer than 8,192 characters or with more than 128 query fields are redacted
entirely to bound parsing work. Other query and fragment parameters 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 @@ -219,9 +219,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 Down Expand Up @@ -58,6 +59,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
93 changes: 93 additions & 0 deletions posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@

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,
resource_listing_response,
resolve_session_and_client,
start_tool_call_lifecycle,
start_tools_list_lifecycle,
Expand All @@ -50,6 +54,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 @@ -73,6 +78,94 @@ 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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

resources/templates/list is not instrumented in any adapter. MCP resource discovery has two methods: resources/list and resources/templates/list. This wrapper covers only ListResourcesRequest/ReadResourceRequest, and _RESOURCE_METHODS in _instrument_v2.py lists only resources/list and resources/read. FastMCP registers a ListResourceTemplatesRequest handler, and templated resources (@mcp.resource("users://{id}")) are advertised only through it. A template-only server therefore shows reads with no matching discovery events, while the README and changeset claim resource discovery is captured. No test registers a templated resource either. Suggestion: wrap ListResourceTemplatesRequest / resources/templates/list in both adapters, or scope the README claim.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in df1c814. resources/templates/list is wrapped on every adapter (low-level v1, FastMCP, v2) and emitted as $mcp_resources_list; $mcp_parameters.request.method separates it from resources/list. Tests register a templated users://{user_id}/profile resource on each adapter.

# Templates are listings too: the captured request method separates
# `resources/templates/list` from `resources/list` on the same event.
(
mcp_types.ListResourceTemplatesRequest,
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,
response=resource_listing_response(event_type, result),
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
82 changes: 82 additions & 0 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,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,
collect_listed_tools,
mutate_tool_schema,
params_to_request_dict,
prepare_request,
record_resource_request,
resource_listing_response,
resolve_session_and_client,
start_tool_call_lifecycle,
start_tools_list_lifecycle,
Expand All @@ -68,6 +72,13 @@
# 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,
# Templates are listings too: the captured request method separates
# `resources/templates/list` from `resources/list` on the same event.
"resources/templates/list": MCPAnalyticsEventType.MCP_RESOURCES_LIST,
"resources/read": MCPAnalyticsEventType.MCP_RESOURCES_READ,
}


def instrument_mcpserver_v2(server: Any, data: MCPAnalyticsData) -> None:
Expand All @@ -86,6 +97,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 @@ -98,6 +111,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 @@ -132,6 +147,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 @@ -465,6 +482,71 @@ 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,
response=resource_listing_response(event_type, result),
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
59 changes: 55 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 @@ -904,3 +903,55 @@ 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}")


def resource_listing_response(event_type: str, result: Any) -> Any:
"""The result an adapter should capture as the event ``response``. A listing
(``resources/list``, ``resources/templates/list``) is metadata — names, uris,
mime types — so it is captured; a read's result is the resource body itself,
which this SDK never captures."""
if event_type != MCPAnalyticsEventType.MCP_RESOURCES_LIST:
return None
return _to_jsonable(result)


async def record_resource_request(
data: MCPAnalyticsData,
session_id: str,
*,
event_type: str,
request: Dict[str, Any],
response: Any = None,
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 a resources listing or read without affecting dispatch."""
try:
params = request.get("params")
uri = params.get("uri") if isinstance(params, dict) else None
event: Dict[str, Any] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

$mcp_resources_list records nothing about the listing outcome. Both wrappers await the handler result and then discard it; record_resource_request receives only the request. The event carries no resource names or URIs, no count, no nextCursor, and no empty-list flag — it can only say a listing happened, in N ms. The sibling record_tools_list captures $mcp_listed_tool_names, the full response, and flags an empty listing as an error. Listing metadata (name/uri/mimeType) is not a resource body, so the README exclusion does not apply. Suggestion: pass the result through and capture listed URIs (sanitized) and a count, for parity with $mcp_tools_list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in df1c814. Listing events now carry the listing result as $mcp_response (names, uris, uriTemplates, mimeTypes, nextCursor), which is metadata rather than a body, so it lines up with $mcp_tools_list. Reads still capture no response. An empty listing is intentionally not flagged as an error: a template-only server legitimately lists zero static resources.

"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),
"response": _wrap_response(response) if response is not None else None,
"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