From b245a3c21fb1ef7e2d96c656d31d0a9047e9fa3d Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 7 Sep 2026 16:33:19 -0300 Subject: [PATCH 1/4] feat(mcp): capture resource discovery and reads --- .sampo/changesets/calm-resource-atlas.md | 5 ++ posthog/mcp/README.md | 3 +- posthog/mcp/__init__.py | 14 ++-- posthog/mcp/_instrument_fastmcp.py | 4 ++ posthog/mcp/_instrument_lowlevel.py | 86 ++++++++++++++++++++++++ posthog/mcp/_instrument_v2.py | 78 +++++++++++++++++++++ posthog/mcp/_instrumentation.py | 49 ++++++++++++-- posthog/test/mcp/test_lowlevel.py | 54 +++++++++++++++ posthog/test/mcp/test_v2_lowlevel.py | 78 ++++++++++++++++++++- 9 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 .sampo/changesets/calm-resource-atlas.md diff --git a/.sampo/changesets/calm-resource-atlas.md b/.sampo/changesets/calm-resource-atlas.md new file mode 100644 index 000000000..b7b038885 --- /dev/null +++ b/.sampo/changesets/calm-resource-atlas.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Capture MCP resource discovery and reads from instrumented servers. diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index a8dcd1020..f6504c46b 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -1,7 +1,8 @@ # 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. ```python from posthog import Posthog diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 3d9a58223..ea4e4dbd5 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -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 @@ -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 diff --git a/posthog/mcp/_instrument_fastmcp.py b/posthog/mcp/_instrument_fastmcp.py index e4d8a84d7..aadaa46ea 100644 --- a/posthog/mcp/_instrument_fastmcp.py +++ b/posthog/mcp/_instrument_fastmcp.py @@ -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, @@ -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 ---------------------------------------------------------- diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index cc1432554..a349d6c21 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -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, @@ -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: @@ -72,6 +76,88 @@ 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, + response=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( diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index 437015bee..84d95b8a8 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -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, @@ -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: @@ -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) @@ -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) @@ -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 @@ -440,6 +453,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=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 ------------------------------------------------------------------- diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index b9c052b0f..afb62ccc7 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -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 @@ -834,3 +833,45 @@ 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], + 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 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), + "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}") diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index eb50ff1e1..f6c557183 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -34,6 +34,35 @@ async def call_tool(name, arguments): return [mcp_types.TextContent(type="text", text=str(arguments.get("msg")))] raise ValueError("boom") + async def list_resources(_request): + return mcp_types.ServerResult( + mcp_types.ListResourcesResult( + resources=[ + mcp_types.Resource( + name="Guide", + uri="file:///guide.md", + mimeType="text/markdown", + ) + ] + ) + ) + + async def read_resource(request): + return mcp_types.ServerResult( + mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri=request.params.uri, + mimeType="text/markdown", + text="# Guide", + ) + ] + ) + ) + + server.request_handlers[mcp_types.ListResourcesRequest] = list_resources + server.request_handlers[mcp_types.ReadResourceRequest] = read_resource + return server @@ -62,6 +91,31 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed and listed[0]["properties"]["$mcp_listed_tool_names"] == ["echo"] +async def test_resource_discovery_and_read_are_captured(): + server = make_server() + client = FakeClient() + instrument(server, client) + + list_handler = server.request_handlers[mcp_types.ListResourcesRequest] + await list_handler(mcp_types.ListResourcesRequest()) + read_handler = server.request_handlers[mcp_types.ReadResourceRequest] + result = await read_handler( + mcp_types.ReadResourceRequest( + params=mcp_types.ReadResourceRequestParams(uri="file:///guide.md") + ) + ) + await _flush() + + assert result.root.contents[0].text == "# Guide" + listed = _events(client, "$mcp_resources_list") + assert len(listed) == 1 + read = _events(client, "$mcp_resource_read") + assert len(read) == 1 + assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" + assert read[0]["properties"]["$mcp_is_error"] is False + assert read[0]["properties"]["$mcp_response"]["contents"][0]["text"] == "# Guide" + + async def test_tool_call_success_captures_intent(): server = make_server() client = FakeClient() diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index eb543f86f..23bb0b336 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -58,13 +58,41 @@ async def on_list_tools(ctx, params): ] ) - return Server( + server = Server( "test-low-v2", version="1.2.3", on_call_tool=on_call_tool, on_list_tools=on_list_tools, ) + async def on_list_resources(ctx, params): + return mcp_types.ListResourcesResult( + resources=[ + mcp_types.Resource( + name="Guide", uri="file:///guide.md", mime_type="text/markdown" + ) + ] + ) + + async def on_read_resource(ctx, params): + return mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents( + uri=params.uri, + mime_type="text/markdown", + text="# Guide", + ) + ] + ) + + server.add_request_handler( + "resources/list", mcp_types.PaginatedRequestParams, on_list_resources + ) + server.add_request_handler( + "resources/read", mcp_types.ReadResourceRequestParams, on_read_resource + ) + return server + async def _call_tool(server, name, arguments, ctx=None): entry = server.get_request_handler("tools/call") @@ -77,6 +105,11 @@ async def _list_tools(server, ctx=None): return await entry.handler(ctx or fake_ctx(method="tools/list"), None) +async def _resource_request(server, method, params=None, ctx=None): + entry = server.get_request_handler(method) + return await entry.handler(ctx or fake_ctx(method=method), params) + + # --- tools/list -------------------------------------------------------------- @@ -100,6 +133,29 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed[0]["properties"]["$mcp_server_name"] == "test-low-v2" +async def test_resource_discovery_and_read_are_captured(): + server = make_server() + client = FakeClient() + instrument(server, client) + + await _resource_request(server, "resources/list") + result = await _resource_request( + server, + "resources/read", + mcp_types.ReadResourceRequestParams(uri="file:///guide.md"), + ) + await _flush() + + assert result.contents[0].text == "# Guide" + listed = _events(client, "$mcp_resources_list") + assert len(listed) == 1 + read = _events(client, "$mcp_resource_read") + assert len(read) == 1 + assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" + assert read[0]["properties"]["$mcp_protocol_version"] == "2026-07-28" + assert read[0]["properties"]["$mcp_response"]["contents"][0]["text"] == "# Guide" + + # --- tools/call -------------------------------------------------------------- @@ -200,13 +256,33 @@ async def late_call_tool(ctx, params): "tools/call", mcp_types.CallToolRequestParams, late_call_tool ) + async def late_read_resource(ctx, params): + return mcp_types.ReadResourceResult( + contents=[ + mcp_types.TextResourceContents(uri=params.uri, text="late resource") + ] + ) + + server.add_request_handler( + "resources/read", mcp_types.ReadResourceRequestParams, late_read_resource + ) + result = await _call_tool(server, "anything", {"context": "late registration"}) + resource = await _resource_request( + server, + "resources/read", + mcp_types.ReadResourceRequestParams(uri="file:///late.txt"), + ) await _flush() assert result.content[0].text == "late ok" + assert resource.contents[0].text == "late resource" calls = _events(client, "$mcp_tool_call") assert len(calls) == 1 assert calls[0]["properties"]["$mcp_tool_name"] == "anything" + reads = _events(client, "$mcp_resource_read") + assert len(reads) == 1 + assert reads[0]["properties"]["$mcp_resource_name"] == "file:///late.txt" async def test_initialize_and_session_reuse_across_calls(): From a341587a545e1a47d35962e203b0e52e8910c65f Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Mon, 7 Sep 2026 16:54:39 -0300 Subject: [PATCH 2/4] fix(mcp): keep resource bodies out of analytics --- posthog/mcp/_instrument_lowlevel.py | 1 - posthog/mcp/_instrument_v2.py | 1 - posthog/mcp/_instrumentation.py | 2 -- posthog/test/mcp/test_lowlevel.py | 2 +- posthog/test/mcp/test_v2_lowlevel.py | 2 +- 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/posthog/mcp/_instrument_lowlevel.py b/posthog/mcp/_instrument_lowlevel.py index a349d6c21..e4eb36238 100644 --- a/posthog/mcp/_instrument_lowlevel.py +++ b/posthog/mcp/_instrument_lowlevel.py @@ -147,7 +147,6 @@ async def handler(req: Any) -> Any: session_id, event_type=event_type, request=request, - response=result, duration_ms=(time.monotonic() - start) * 1000, client_name=client_name, client_version=client_version, diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index 84d95b8a8..411d2329a 100644 --- a/posthog/mcp/_instrument_v2.py +++ b/posthog/mcp/_instrument_v2.py @@ -505,7 +505,6 @@ async def handler(ctx: Any, params: Any) -> Any: session_id, event_type=event_type, request=request, - response=result, duration_ms=(time.monotonic() - start) * 1000, client_name=client_name, client_version=client_version, diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index afb62ccc7..b8ef4d2a0 100644 --- a/posthog/mcp/_instrumentation.py +++ b/posthog/mcp/_instrumentation.py @@ -841,7 +841,6 @@ async def record_resource_request( *, event_type: str, request: Dict[str, Any], - response: Any = None, error: Any = None, duration_ms: Optional[float] = None, client_name: Optional[str] = None, @@ -860,7 +859,6 @@ async def record_resource_request( 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, diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index f6c557183..d282ff7bb 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -113,7 +113,7 @@ async def test_resource_discovery_and_read_are_captured(): assert len(read) == 1 assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" assert read[0]["properties"]["$mcp_is_error"] is False - assert read[0]["properties"]["$mcp_response"]["contents"][0]["text"] == "# Guide" + assert "$mcp_response" not in read[0]["properties"] async def test_tool_call_success_captures_intent(): diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index 23bb0b336..1fa15cca3 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -153,7 +153,7 @@ async def test_resource_discovery_and_read_are_captured(): assert len(read) == 1 assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" assert read[0]["properties"]["$mcp_protocol_version"] == "2026-07-28" - assert read[0]["properties"]["$mcp_response"]["contents"][0]["text"] == "# Guide" + assert "$mcp_response" not in read[0]["properties"] # --- tools/call -------------------------------------------------------------- From 62caaf165743df8de0dc4a9c5a3f27fddd802e48 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 19:03:23 -0300 Subject: [PATCH 3/4] fix(mcp): redact credentials in captured resource addresses Apply existing credential redaction to resource-read names before the primary event and exception sibling are built. Preserve the original URI and resource result or exception received by the caller. Extend the existing resource tests with successful and failing reads containing an invented token; the two new cases fail before this fix under each MCP major. Document the capture boundary and before_send. Validation: MCP v1 245 passed; MCP v2 225 passed and 13 expected skips. Ruff lint and formatting pass. Mypy baseline passes (227 source files). --- posthog/mcp/README.md | 5 ++ posthog/mcp/_sanitization.py | 6 +++ posthog/test/mcp/test_lowlevel.py | 72 +++++++++++++++++++++------- posthog/test/mcp/test_v2_lowlevel.py | 64 +++++++++++++++++++------ 4 files changed, 114 insertions(+), 33 deletions(-) diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index f6504c46b..252d665a9 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -4,6 +4,11 @@ Product analytics for Model Context Protocol servers. Wrap a Python MCP server s tool calls, agent intent, resource discovery and reads, and failures are captured to PostHog as `$mcp_*` events. +Resource bodies are not captured. Resource addresses use the same credential +redaction as request parameters, including on failed reads. Requests and responses +keep their original addresses. Use `before_send` to remove any additional +application-specific sensitive data. + ```python from posthog import Posthog from posthog.mcp import instrument diff --git a/posthog/mcp/_sanitization.py b/posthog/mcp/_sanitization.py index fd547f418..06ad641cb 100644 --- a/posthog/mcp/_sanitization.py +++ b/posthog/mcp/_sanitization.py @@ -13,6 +13,8 @@ import re from typing import Any, Dict +from ._event_types import MCPAnalyticsEventType + # SDK-injected arguments stripped from captured $mcp_parameters (they surface as # dedicated properties: $mcp_intent and $mcp_conversation_id). _INJECTED_ARGUMENT_NAMES = ("context", "conversation_id") @@ -105,6 +107,10 @@ def sanitize_event(event: Dict[str, Any]) -> Dict[str, Any]: if result.get("parameters") is not None: result["parameters"] = sanitize_captured_value(result["parameters"]) + if result.get("event_type") == MCPAnalyticsEventType.MCP_RESOURCES_READ: + if result.get("resource_name") is not None: + result["resource_name"] = sanitize_captured_value(result["resource_name"]) + # The intent comes straight from an agent-narrated `context` string, so it # can contain a secret the LLM read aloud. Redact it like any other value. if result.get("user_intent") is not None: diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index d282ff7bb..821adb62f 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -1,5 +1,9 @@ """End-to-end tests for the low-level mcp.server.Server adapter (Milestone 3).""" +import json + +import pytest + import mcp.types as mcp_types from mcp.server.lowlevel import Server @@ -11,7 +15,7 @@ ) -def make_server(): +def make_server(*, resource_error: bool = False) -> Server: server = Server("test-lowlevel") @server.list_tools() @@ -48,6 +52,8 @@ async def list_resources(_request): ) async def read_resource(request): + if resource_error: + raise ValueError(f"Cannot read {request.params.uri}") return mcp_types.ServerResult( mcp_types.ReadResourceResult( contents=[ @@ -91,29 +97,59 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed and listed[0]["properties"]["$mcp_listed_tool_names"] == ["echo"] -async def test_resource_discovery_and_read_are_captured(): - server = make_server() +@pytest.mark.parametrize( + "uri, captured_uri, resource_error", + [ + ("file:///guide.md", "file:///guide.md", False), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=[redacted]", + False, + ), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=[redacted]", + True, + ), + ], +) +async def test_resource_discovery_and_read_are_captured( + uri: str, captured_uri: str, resource_error: bool +) -> None: + server = make_server(resource_error=resource_error) client = FakeClient() instrument(server, client) - list_handler = server.request_handlers[mcp_types.ListResourcesRequest] - await list_handler(mcp_types.ListResourcesRequest()) - read_handler = server.request_handlers[mcp_types.ReadResourceRequest] - result = await read_handler( - mcp_types.ReadResourceRequest( - params=mcp_types.ReadResourceRequestParams(uri="file:///guide.md") - ) + await server.request_handlers[mcp_types.ListResourcesRequest]( + mcp_types.ListResourcesRequest() + ) + request = mcp_types.ReadResourceRequest( + params=mcp_types.ReadResourceRequestParams(uri=uri) ) + read = server.request_handlers[mcp_types.ReadResourceRequest](request) + if resource_error: + with pytest.raises(ValueError) as caught: + await read + assert str(caught.value) == f"Cannot read {uri}" + else: + result = await read + assert result.root.contents[0].text == "# Guide" + assert str(result.root.contents[0].uri) == uri await _flush() - assert result.root.contents[0].text == "# Guide" - listed = _events(client, "$mcp_resources_list") - assert len(listed) == 1 - read = _events(client, "$mcp_resource_read") - assert len(read) == 1 - assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" - assert read[0]["properties"]["$mcp_is_error"] is False - assert "$mcp_response" not in read[0]["properties"] + assert len(_events(client, "$mcp_resources_list")) == 1 + reads = _events(client, "$mcp_resource_read") + assert len(reads) == 1 + props = reads[0]["properties"] + assert props["$mcp_resource_name"] == captured_uri + assert props["$mcp_parameters"]["request"]["params"]["uri"] == captured_uri + assert props["$mcp_is_error"] is resource_error + assert "$mcp_response" not in props + exceptions = _events(client, "$exception") + assert len(exceptions) == int(resource_error) + if resource_error: + assert exceptions[0]["properties"]["$mcp_resource_name"] == captured_uri + assert "phx_EXAMPLEONLYFAKEVALUE00000000000" not in json.dumps(client.events) async def test_tool_call_success_captures_intent(): diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index 1fa15cca3..f239a44a5 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -7,6 +7,8 @@ converting to ``is_error`` results. """ +import json + import pytest import mcp.types as mcp_types @@ -22,7 +24,7 @@ from posthog.test.mcp._helpers_v2 import fake_ctx -def make_server(): +def make_server(*, resource_error: bool = False) -> Server: async def on_call_tool(ctx, params): if params.name == "boom": raise ValueError("explode") @@ -75,6 +77,8 @@ async def on_list_resources(ctx, params): ) async def on_read_resource(ctx, params): + if resource_error: + raise ValueError(f"Cannot read {params.uri}") return mcp_types.ReadResourceResult( contents=[ mcp_types.TextResourceContents( @@ -133,27 +137,57 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed[0]["properties"]["$mcp_server_name"] == "test-low-v2" -async def test_resource_discovery_and_read_are_captured(): - server = make_server() +@pytest.mark.parametrize( + "uri, captured_uri, resource_error", + [ + ("file:///guide.md", "file:///guide.md", False), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=[redacted]", + False, + ), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=[redacted]", + True, + ), + ], +) +async def test_resource_discovery_and_read_are_captured( + uri: str, captured_uri: str, resource_error: bool +) -> None: + server = make_server(resource_error=resource_error) client = FakeClient() instrument(server, client) await _resource_request(server, "resources/list") - result = await _resource_request( - server, - "resources/read", - mcp_types.ReadResourceRequestParams(uri="file:///guide.md"), + read = _resource_request( + server, "resources/read", mcp_types.ReadResourceRequestParams(uri=uri) ) + if resource_error: + with pytest.raises(ValueError) as caught: + await read + assert str(caught.value) == f"Cannot read {uri}" + else: + result = await read + assert result.contents[0].text == "# Guide" + assert str(result.contents[0].uri) == uri await _flush() - assert result.contents[0].text == "# Guide" - listed = _events(client, "$mcp_resources_list") - assert len(listed) == 1 - read = _events(client, "$mcp_resource_read") - assert len(read) == 1 - assert read[0]["properties"]["$mcp_resource_name"] == "file:///guide.md" - assert read[0]["properties"]["$mcp_protocol_version"] == "2026-07-28" - assert "$mcp_response" not in read[0]["properties"] + assert len(_events(client, "$mcp_resources_list")) == 1 + reads = _events(client, "$mcp_resource_read") + assert len(reads) == 1 + props = reads[0]["properties"] + assert props["$mcp_resource_name"] == captured_uri + assert props["$mcp_parameters"]["request"]["params"]["uri"] == captured_uri + assert props["$mcp_is_error"] is resource_error + assert "$mcp_response" not in props + exceptions = _events(client, "$exception") + assert len(exceptions) == int(resource_error) + if resource_error: + assert exceptions[0]["properties"]["$mcp_resource_name"] == captured_uri + assert "phx_EXAMPLEONLYFAKEVALUE00000000000" not in json.dumps(client.events) + assert props["$mcp_protocol_version"] == "2026-07-28" # --- tools/call -------------------------------------------------------------- From 6dea4debebd9c0f7802e079eb610984a590d1b06 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Tue, 8 Sep 2026 19:43:40 -0300 Subject: [PATCH 4/4] fix(mcp): redact credentials embedded in captured URLs Parse captured URLs to remove userinfo and credential query values, including common signed URL fields. Apply the same sanitization to URLs inside exception messages without changing handler requests or responses. Document the limits of key-based URL redaction. Verification: reproduced the credential leak before the fix. MCP v1 suite: 260 passed; v2 suite: 240 passed, 13 skipped. Ruff check and format passed; mypy baseline passed for 227 files. Regression coverage includes encoded keys, duplicate query parameters, malformed URLs, and success/error events. --- posthog/mcp/README.md | 10 +++-- posthog/mcp/_sanitization.py | 33 +++++++++++++++++ posthog/test/mcp/test_lowlevel.py | 55 ++++++++++++++++++++++++++-- posthog/test/mcp/test_pipeline.py | 37 +++++++++++++++++++ posthog/test/mcp/test_v2_lowlevel.py | 55 ++++++++++++++++++++++++++-- 5 files changed, 180 insertions(+), 10 deletions(-) diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index 252d665a9..7366ceba2 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -4,10 +4,12 @@ Product analytics for Model Context Protocol servers. Wrap a Python MCP server s tool calls, agent intent, resource discovery and reads, and failures are captured to PostHog as `$mcp_*` events. -Resource bodies are not captured. Resource addresses use the same credential -redaction as request parameters, including on failed reads. Requests and responses -keep their original addresses. Use `before_send` to remove any additional -application-specific sensitive data. +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 diff --git a/posthog/mcp/_sanitization.py b/posthog/mcp/_sanitization.py index 06ad641cb..21f02e84f 100644 --- a/posthog/mcp/_sanitization.py +++ b/posthog/mcp/_sanitization.py @@ -12,6 +12,7 @@ import re from typing import Any, Dict +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from ._event_types import MCPAnalyticsEventType @@ -29,6 +30,37 @@ re.IGNORECASE, ) +_URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.-]{0,63}://[^\s<>\"']+", re.IGNORECASE) +_SENSITIVE_QUERY_KEY_PATTERN = re.compile( + r"^(auth|key|credential|signature|sig|AWSAccessKeyId|GoogleAccessId|" + r"Policy|Key-Pair-Id|X-Amz-(Credential|Signature|Security-Token)|" + r"X-Goog-(Credential|Signature))$", + re.IGNORECASE, +) + + +def _sanitize_url(match: re.Match[str]) -> str: + value = match.group(0) + try: + url = urlsplit(value) + query = parse_qsl(url.query, keep_blank_values=True) + sanitized_query = [ + (key, _REDACTED_VALUE) + if _should_redact_key(key) or _SENSITIVE_QUERY_KEY_PATTERN.fullmatch(key) + else (key, item) + for key, item in query + ] + netloc = url.netloc + if "@" in netloc: + netloc = "%5Bredacted%5D@" + netloc.rsplit("@", 1)[1] + if netloc == url.netloc and sanitized_query == query: + return value + return urlunsplit( + (url.scheme, netloc, url.path, urlencode(sanitized_query), url.fragment) + ) + except ValueError: + return _REDACTED_VALUE + def _is_record(value: Any) -> bool: return isinstance(value, dict) @@ -41,6 +73,7 @@ def _should_redact_key(key: str) -> bool: def _sanitize_string(value: str) -> str: if len(value) >= _SIZE_GATE and _BASE64_PATTERN.match(value): return "[binary data redacted - not supported by PostHog MCP analytics]" + value = _URL_PATTERN.sub(_sanitize_url, value) return _redact_secret_tokens(_POSTHOG_TOKEN_PATTERN.sub(_REDACTED_VALUE, value)) diff --git a/posthog/test/mcp/test_lowlevel.py b/posthog/test/mcp/test_lowlevel.py index 821adb62f..abf0754d4 100644 --- a/posthog/test/mcp/test_lowlevel.py +++ b/posthog/test/mcp/test_lowlevel.py @@ -101,14 +101,54 @@ async def test_list_tools_injects_optional_context_and_captures(): "uri, captured_uri, resource_error", [ ("file:///guide.md", "file:///guide.md", False), + ( + "https://fakeuser:fakepass@example.com/guide", + "https://%5Bredacted%5D@example.com/guide", + False, + ), + ( + "https://fakeuser:fakepass@example.com/guide", + "https://%5Bredacted%5D@example.com/guide", + True, + ), + ( + "https://example.com/guide?token=fakesecret&chapter=intro", + "https://example.com/guide?token=%5Bredacted%5D&chapter=intro", + False, + ), + ( + "https://example.com/guide?token=fakesecret&chapter=intro", + "https://example.com/guide?token=%5Bredacted%5D&chapter=intro", + True, + ), + ( + "https://example.com/guide?access_token=fakeaccess&X-Amz-Credential=fakecredential&X-Amz-Signature=fakesignature", + "https://example.com/guide?access_token=%5Bredacted%5D&X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D", + False, + ), + ( + "https://example.com/guide?access_token=fakeaccess&X-Amz-Credential=fakecredential&X-Amz-Signature=fakesignature", + "https://example.com/guide?access_token=%5Bredacted%5D&X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D", + True, + ), + ( + "ui://guide/page?%74oken=fakesecret&TOKEN=fakeaccess&chapter=intro#section", + "ui://guide/page?token=%5Bredacted%5D&TOKEN=%5Bredacted%5D&chapter=intro#section", + False, + ), + ( + "ui://guide/page?%74oken=fakesecret&TOKEN=fakeaccess&chapter=intro#section", + "ui://guide/page?token=%5Bredacted%5D&TOKEN=%5Bredacted%5D&chapter=intro#section", + True, + ), ( "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", - "https://example.com/guide?token=[redacted]", + "https://example.com/guide?token=%5Bredacted%5D", False, ), ( "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", - "https://example.com/guide?token=[redacted]", + "https://example.com/guide?token=%5Bredacted%5D", True, ), ], @@ -149,7 +189,16 @@ async def test_resource_discovery_and_read_are_captured( assert len(exceptions) == int(resource_error) if resource_error: assert exceptions[0]["properties"]["$mcp_resource_name"] == captured_uri - assert "phx_EXAMPLEONLYFAKEVALUE00000000000" not in json.dumps(client.events) + for secret in ( + "phx_EXAMPLEONLYFAKEVALUE00000000000", + "fakeuser", + "fakepass", + "fakesecret", + "fakeaccess", + "fakecredential", + "fakesignature", + ): + assert secret not in json.dumps(client.events) async def test_tool_call_success_captures_intent(): diff --git a/posthog/test/mcp/test_pipeline.py b/posthog/test/mcp/test_pipeline.py index f57c1572b..fbdc7f87c 100644 --- a/posthog/test/mcp/test_pipeline.py +++ b/posthog/test/mcp/test_pipeline.py @@ -2,6 +2,8 @@ from datetime import datetime, timezone +import pytest + from posthog.mcp.constants import ( POSTHOG_MCP_ANALYTICS_SOURCE, PostHogMCPAnalyticsEvent, @@ -75,6 +77,41 @@ def test_sanitize_redacts_large_base64(): assert sanitize_captured_value(blob).startswith("[binary data redacted") +@pytest.mark.parametrize( + "value, expected", + [ + ( + "https://example.com/guide?token=fakesecret&token=fakeaccess&empty=", + "https://example.com/guide?token=%5Bredacted%5D&token=%5Bredacted%5D&empty=", + ), + ( + "https://example.com/guide?X-Goog-Credential=fakecredential&X-Goog-Signature=fakesignature", + "https://example.com/guide?X-Goog-Credential=%5Bredacted%5D&X-Goog-Signature=%5Bredacted%5D", + ), + ( + "https://example.com/guide?sig=fakesignature&Signature=fakesignature&X-Amz-Security-Token=fakesecret", + "https://example.com/guide?sig=%5Bredacted%5D&Signature=%5Bredacted%5D&X-Amz-Security-Token=%5Bredacted%5D", + ), + ( + "https://fakeuser@example.com/guide", + "https://%5Bredacted%5D@example.com/guide", + ), + ( + "https://example.com/guide?%61=hello%20world&empty=#part", + "https://example.com/guide?%61=hello%20world&empty=#part", + ), + ( + "Cannot read https://fakeuser:fakepass@example.com/guide or https://example.com/guide?token=fakesecret", + "Cannot read https://%5Bredacted%5D@example.com/guide or https://example.com/guide?token=%5Bredacted%5D", + ), + ("https://fakeuser:fakepass@[invalid/guide?token=fakesecret", "[redacted]"), + ], +) +def test_sanitize_url_credentials(value: str, expected: str) -> None: + assert sanitize_captured_value(value) == expected + assert sanitize_captured_value(expected) == expected + + def test_sanitize_event_replaces_image_and_audio_blocks(): event = { "response": { diff --git a/posthog/test/mcp/test_v2_lowlevel.py b/posthog/test/mcp/test_v2_lowlevel.py index f239a44a5..ce3d50332 100644 --- a/posthog/test/mcp/test_v2_lowlevel.py +++ b/posthog/test/mcp/test_v2_lowlevel.py @@ -141,14 +141,54 @@ async def test_list_tools_injects_optional_context_and_captures(): "uri, captured_uri, resource_error", [ ("file:///guide.md", "file:///guide.md", False), + ( + "https://fakeuser:fakepass@example.com/guide", + "https://%5Bredacted%5D@example.com/guide", + False, + ), + ( + "https://fakeuser:fakepass@example.com/guide", + "https://%5Bredacted%5D@example.com/guide", + True, + ), + ( + "https://example.com/guide?token=fakesecret&chapter=intro", + "https://example.com/guide?token=%5Bredacted%5D&chapter=intro", + False, + ), + ( + "https://example.com/guide?token=fakesecret&chapter=intro", + "https://example.com/guide?token=%5Bredacted%5D&chapter=intro", + True, + ), + ( + "https://example.com/guide?access_token=fakeaccess&X-Amz-Credential=fakecredential&X-Amz-Signature=fakesignature", + "https://example.com/guide?access_token=%5Bredacted%5D&X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D", + False, + ), + ( + "https://example.com/guide?access_token=fakeaccess&X-Amz-Credential=fakecredential&X-Amz-Signature=fakesignature", + "https://example.com/guide?access_token=%5Bredacted%5D&X-Amz-Credential=%5Bredacted%5D&X-Amz-Signature=%5Bredacted%5D", + True, + ), + ( + "ui://guide/page?%74oken=fakesecret&TOKEN=fakeaccess&chapter=intro#section", + "ui://guide/page?token=%5Bredacted%5D&TOKEN=%5Bredacted%5D&chapter=intro#section", + False, + ), + ( + "ui://guide/page?%74oken=fakesecret&TOKEN=fakeaccess&chapter=intro#section", + "ui://guide/page?token=%5Bredacted%5D&TOKEN=%5Bredacted%5D&chapter=intro#section", + True, + ), ( "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", - "https://example.com/guide?token=[redacted]", + "https://example.com/guide?token=%5Bredacted%5D", False, ), ( "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", - "https://example.com/guide?token=[redacted]", + "https://example.com/guide?token=%5Bredacted%5D", True, ), ], @@ -186,7 +226,16 @@ async def test_resource_discovery_and_read_are_captured( assert len(exceptions) == int(resource_error) if resource_error: assert exceptions[0]["properties"]["$mcp_resource_name"] == captured_uri - assert "phx_EXAMPLEONLYFAKEVALUE00000000000" not in json.dumps(client.events) + for secret in ( + "phx_EXAMPLEONLYFAKEVALUE00000000000", + "fakeuser", + "fakepass", + "fakesecret", + "fakeaccess", + "fakecredential", + "fakesignature", + ): + assert secret not in json.dumps(client.events) assert props["$mcp_protocol_version"] == "2026-07-28"