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..7366ceba2 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -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 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..e4eb36238 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,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( diff --git a/posthog/mcp/_instrument_v2.py b/posthog/mcp/_instrument_v2.py index 437015bee..411d2329a 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,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 ------------------------------------------------------------------- diff --git a/posthog/mcp/_instrumentation.py b/posthog/mcp/_instrumentation.py index b9c052b0f..b8ef4d2a0 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,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}") diff --git a/posthog/mcp/_sanitization.py b/posthog/mcp/_sanitization.py index fd547f418..21f02e84f 100644 --- a/posthog/mcp/_sanitization.py +++ b/posthog/mcp/_sanitization.py @@ -12,6 +12,9 @@ import re from typing import Any, Dict +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from ._event_types import MCPAnalyticsEventType # SDK-injected arguments stripped from captured $mcp_parameters (they surface as # dedicated properties: $mcp_intent and $mcp_conversation_id). @@ -27,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) @@ -39,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)) @@ -105,6 +140,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 eb50ff1e1..abf0754d4 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() @@ -34,6 +38,37 @@ 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): + if resource_error: + raise ValueError(f"Cannot read {request.params.uri}") + 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 +97,110 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed and listed[0]["properties"]["$mcp_listed_tool_names"] == ["echo"] +@pytest.mark.parametrize( + "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=%5Bredacted%5D", + False, + ), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=%5Bredacted%5D", + 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 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 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 + 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(): server = make_server() client = FakeClient() 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 eb543f86f..ce3d50332 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") @@ -58,13 +60,43 @@ 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): + if resource_error: + raise ValueError(f"Cannot read {params.uri}") + 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 +109,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 +137,108 @@ async def test_list_tools_injects_optional_context_and_captures(): assert listed[0]["properties"]["$mcp_server_name"] == "test-low-v2" +@pytest.mark.parametrize( + "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=%5Bredacted%5D", + False, + ), + ( + "https://example.com/guide?token=phx_EXAMPLEONLYFAKEVALUE00000000000", + "https://example.com/guide?token=%5Bredacted%5D", + 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") + 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 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 + 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" + + # --- tools/call -------------------------------------------------------------- @@ -200,13 +339,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():