Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a52a78a
test(mcp): dual-major test setup — run the MCP suite against SDK v1 a…
gesh Aug 20, 2026
004c745
feat(mcp): support MCP Python SDK v2 and the 2026-07-28 spec revision
gesh Aug 20, 2026
9fda7cc
chore(mcp): refresh public API snapshot for the 0.3.0 sdk-surface bump
gesh Aug 20, 2026
021823f
fix(mcp): capture only a scalar projection of extra on $identify events
gesh Aug 20, 2026
fddfaf1
ci(mcp): name the MCP gate per SDK major — MCP SDK v1/v2 (Python X.Y)
gesh Aug 20, 2026
94ac113
fix(mcp): deliver the conversation handle via structuredContent too
gesh Aug 20, 2026
e9c490d
feat(mcp): uniform ctx in callbacks + exported get_request_headers
gesh Aug 20, 2026
5ed2086
fix(mcp): review findings — never mutate the caller's result, guard c…
gesh Aug 20, 2026
07dd1c8
refactor: apply simplify pass
gesh Aug 20, 2026
e335876
fix(mcp): finish the tool-cache fix on the low-level adapter
gesh Aug 20, 2026
45f5630
fix(mcp): don't require a parameter we strip before the SDK validates
gesh Aug 20, 2026
d7f2f39
Merge remote-tracking branch 'origin/main' into posthog-code/mcp-sdk-v2
gesh Aug 20, 2026
f8f4c1a
docs(mcp): reframe the changeset around cross-SDK parity
gesh Aug 21, 2026
3c14ab0
feat(mcp): emit $mcp_error_message and $mcp_error_type
gesh Aug 21, 2026
c988e85
fix(mcp): sanitize exception messages before they leave
gesh Aug 21, 2026
fb4e0ff
docs(mcp): state the real redaction scope on exception values
gesh Aug 21, 2026
ff81024
fix(mcp): only anchor a conversation handle the agent has confirmed
gesh Aug 21, 2026
68d8727
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
36a1e7d
fix(mcp): carry the conversation handle as data, not an instruction
gesh Aug 21, 2026
1485c11
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
d76ac48
fix(mcp): anchor the minting call too, once delivery is confirmed
gesh Aug 21, 2026
7ab0498
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
80c9c35
fix(mcp): settle the shared session before the tool body runs
gesh Aug 21, 2026
c61f8ff
Merge branch 'posthog-code/mcp-sdk-v2' into posthog-code/mcp-error-pr…
gesh Aug 21, 2026
8b7e06c
fix(mcp): redact non-PostHog credentials in captured strings
gesh Aug 21, 2026
c86b06d
Merge branch 'main' into posthog-code/mcp-error-properties
gesh Aug 21, 2026
c5542de
Merge remote-tracking branch 'origin/main' into posthog-code/mcp-erro…
gesh Aug 21, 2026
de4250f
chore(mcp): drop the changeset already consumed by v7.40.0
gesh Aug 21, 2026
c47606f
Merge branch 'posthog-code/mcp-error-properties' of https://github.co…
gesh Aug 21, 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/mcp-error-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
posthog: minor
---

feat(mcp): emit `$mcp_error_message` and `$mcp_error_type` on failed MCP events. The reason a tool call failed previously lived only on the sibling `$exception` event, so PostHog's failures view — which reads the scalars off the primary event — showed empty error rows for every Python-backed MCP server, and switching off `enable_exception_autocapture` removed the reason entirely. Both values are read from the same `$exception_list` the sibling carries, so the two surfaces can never disagree, and the message inherits the existing 2048-character cap. `PostHogMCP.capture_tool_call()` and `capture_tools_list()` take a new optional `error_type` for custom dispatchers that want a coarse category (`"validation"`, `"timeout"`) instead of the thrown class name. Exception messages are also redacted before they leave — previously nothing sanitized the error payload, so the `$exception` sibling had been shipping them raw. Credential-looking words go through the SDK's own detector (entropy, known key formats, PEM markers), per word, so a message like `auth failed for sk-...` keeps its diagnostic text and loses only the key. Parity with `@posthog/mcp`, which sanitizes exception values the same way.
1 change: 1 addition & 0 deletions posthog/mcp/_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def capture_event(
"user_intent_source": event_input.get("user_intent_source"),
"is_error": event_input.get("is_error"),
"error": event_input.get("error"),
"error_type": event_input.get("error_type"),
"conversation_id": event_input.get("conversation_id"),
"properties": event_input.get("properties"),
}
Expand Down
32 changes: 32 additions & 0 deletions posthog/mcp/_posthog_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
properties[_P.INTENT_SOURCE] = event["user_intent_source"]
if event.get("is_error") is not None:
properties[_P.IS_ERROR] = event["is_error"]
if event.get("is_error"):
_add_error_details(event, properties)
if event.get("parameters") is not None:
properties[_P.PARAMETERS] = event["parameters"]
if event.get("response") is not None:
Expand All @@ -149,6 +151,36 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
properties["$set"] = {**identify_actor_data}


def _add_error_details(event: Event, properties: Dict[str, Any]) -> None:
"""Surface the failure reason on the primary event itself.

Without these the dashboard has to join to the ``$exception`` sibling to
know *why* a call failed — and that sibling can be switched off with
``enable_exception_autocapture``, or never emitted when no error value was
passed. Both values are read off the ``$exception_list`` the sibling would
carry, so the two always agree; the message is already bounded to
``_MAX_ERROR_MESSAGE_LENGTH`` because truncation runs before this mapping.
"""
first: Dict[str, Any] = {}
error = event.get("error")
if isinstance(error, dict):
exception_list = error.get("$exception_list")
if isinstance(exception_list, list) and exception_list:
candidate = exception_list[0]
if isinstance(candidate, dict):
first = candidate

# An explicit coarse category (e.g. "validation", "timeout") beats the
# thrown type; a custom dispatcher can pass one that means something to the
# product, where the class name rarely does.
error_type = event.get("error_type") or first.get("type")
if error_type:
properties[_P.ERROR_TYPE] = error_type
message = first.get("value")
if message:
properties[_P.ERROR_MESSAGE] = message
Comment on lines +179 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Error message bypasses sanitization

When a failed MCP call's exception message contains a token or another sensitive value, _add_error_details copies the unsanitized $exception_list value into $mcp_error_message, causing sensitive text to be transmitted on the primary event even when exception autocapture is disabled.

How this was verified: The MCP sanitizer processes response, parameters, and user intent but not the error payload read by this mapping.

Knowledge Base Used: MCP Instrumentation (posthog/mcp)

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/mcp/_posthog_events.py
Line: 179-181

Comment:
**Error message bypasses sanitization**

When a failed MCP call's exception message contains a token or another sensitive value, `_add_error_details` copies the unsanitized `$exception_list` value into `$mcp_error_message`, causing sensitive text to be transmitted on the primary event even when exception autocapture is disabled.

**How this was verified:** The MCP sanitizer processes response, parameters, and user intent but not the error payload read by this mapping.

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

---

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid, and broader than reported — fixed in c988e85. sanitize_event covered response/parameters/user_intent but never error, so the $exception sibling has been shipping unredacted exception messages since long before this property existed (@posthog/mcp sanitizes it via sanitizeExceptionValues; Python never ported that). Now every $exception_list frame's value goes through the same sanitizer, which covers both surfaces since the scalar is derived after sanitization runs — regression test verified to fail without the fix.



def _add_custom_properties(event: Event, properties: Dict[str, Any]) -> None:
custom = event.get("properties")
if custom:
Expand Down
69 changes: 66 additions & 3 deletions posthog/mcp/_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,38 @@ 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]"
return _POSTHOG_TOKEN_PATTERN.sub(_REDACTED_VALUE, value)
return _redact_secret_tokens(_POSTHOG_TOKEN_PATTERN.sub(_REDACTED_VALUE, value))


def _redact_secret_tokens(value: str) -> str:
"""Redact credential-looking words, leaving the surrounding text intact.

The PostHog-token pattern above only knows ``phc_``/``phx_``; a failure
message like ``auth failed for sk-proj-...`` carries someone else's key.
Rather than enumerate every vendor's format — an arms race that fails
quietly in both directions — this reuses the SDK's own detector
(``exception_utils._looks_like_secret``: entropy, known formats such as AWS
key ids, PEM markers), which the code-variables path already ships.

Applied per whitespace-separated token, not to the whole string: redacting
an entire exception message would destroy the diagnostic value that
``$mcp_error_message`` exists to provide, and ordinary prose is left alone
because no single word in it looks like a credential.
"""
if " " not in value:
return _REDACTED_VALUE if _is_secret(value) else value
return " ".join(
_REDACTED_VALUE if _is_secret(word) else word for word in value.split(" ")
)


def _is_secret(word: str) -> bool:
try:
from posthog.exception_utils import _looks_like_secret

return bool(word) and _looks_like_secret(word)
except Exception: # noqa: BLE001 - redaction must never break capture
return False


def sanitize_captured_value(value: Any) -> Any:
Expand All @@ -64,8 +95,8 @@ def sanitize_captured_value(value: Any) -> Any:


def sanitize_event(event: Dict[str, Any]) -> Dict[str, Any]:
"""Sanitize an event's response, parameters, and user_intent. Returns a new
shallow copy; does not mutate the input."""
"""Sanitize an event's response, parameters, user_intent and error. Returns
a new shallow copy; does not mutate the input."""
result = {**event}

if result.get("response") is not None:
Expand All @@ -79,9 +110,41 @@ def sanitize_event(event: Dict[str, Any]) -> Dict[str, Any]:
if result.get("user_intent") is not None:
result["user_intent"] = sanitize_captured_value(result["user_intent"])

# An exception message is free text a server wrote, and it reaches PostHog
# on the $exception sibling and — since it is also surfaced as
# $mcp_error_message — on the primary event, so run it through the same
# sanitizer as every other captured value.
#
# That sanitizer redacts PostHog tokens and sensitive-looking keys; it is
# deliberately not a general credential scrubber, because enumerating every
# vendor's key format is an arms race that fails quietly in both directions.
# A host with strict requirements should gate free text in `before_send`.
# Same scope as @posthog/mcp's sanitizeCapturedValue.
if result.get("error") is not None:
result["error"] = _sanitize_exception_values(result["error"])

return result


def _sanitize_exception_values(error: Any) -> Any:
"""Redact the ``value`` of every frame in an ``$exception_list``, leaving
the rest of the error-tracking shape untouched."""
if not isinstance(error, dict):
return error
exception_list = error.get("$exception_list")
if not isinstance(exception_list, list):
return error
return {
**error,
"$exception_list": [
{**exception, "value": sanitize_captured_value(exception.get("value"))}
Comment thread
veria-ai[bot] marked this conversation as resolved.

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.

blocking: sanitize_captured_value() only recognizes ph[a-z]_... tokens in free-text strings. Consequently, an exception such as AuthError("auth failed for sk-proj-...") uploads the OpenAI key unchanged through both $mcp_error_message and $exception. This is newly exposed on the primary event when mcp_exception_autocapture=False, where no error text was previously sent. Apply bounded detection/redaction for common credential formats—ideally sharing the repository’s existing secret-detection machinery—and add coverage for a non-PostHog credential.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right and my earlier reasoning was wrong — fixed in c61f8ff. I'd argued to veria that enumerating vendor key formats is an arms race, but that doesn't apply here: posthog-python already ships exception_utils._looks_like_secret (entropy + known formats like AWS key ids + PEM markers) for the code-variables path, so reusing it needs no vendor table. Applied per whitespace-separated word rather than to the whole string, so auth failed for sk-proj-... becomes auth failed for [redacted] and keeps the diagnostic text that $mcp_error_message exists for, while ordinary messages are untouched. It covers every captured string, not just error values, so parameters and responses benefit too — and your point about mcp_exception_autocapture=False is the one that changed my mind, since that configuration genuinely sent no error text before this PR. Tests added for both a non-PostHog credential and an ordinary message. Note this now goes further than @posthog/mcp, which still only strips PostHog tokens; I'll raise porting it there.

if isinstance(exception, dict)
else exception
for exception in exception_list
],
}


def _sanitize_response(response: Any) -> Any:
if response is None or not isinstance(response, (dict, list, str)):
return sanitize_captured_value(response)
Expand Down
1 change: 1 addition & 0 deletions posthog/mcp/_truncation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
("server_version", _MAX_METADATA_LENGTH),
("client_name", _MAX_METADATA_LENGTH),
("client_version", _MAX_METADATA_LENGTH),
("error_type", _MAX_METADATA_LENGTH),
)

_NORMALIZED_FIELDS = ("parameters", "response", "identify_actor_data", "error")
Expand Down
2 changes: 2 additions & 0 deletions posthog/mcp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class PostHogMCPAnalyticsProperty:
PROTOCOL_VERSION = "$mcp_protocol_version"
CONVERSATION_ID = "$mcp_conversation_id"
DURATION_MS = "$mcp_duration_ms"
ERROR_MESSAGE = "$mcp_error_message"
ERROR_TYPE = "$mcp_error_type"
IS_ERROR = "$mcp_is_error"
INTENT = "$mcp_intent"
INTENT_SOURCE = "$mcp_intent_source"
Expand Down
4 changes: 4 additions & 0 deletions posthog/mcp/posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def capture_tool_call(
duration_ms: Optional[float] = None,
is_error: bool = False,
error: Any = None,
error_type: Optional[str] = None,
category: Optional[str] = None,
tool_description: Optional[str] = None,
protocol_version: Optional[str] = None,
Expand Down Expand Up @@ -115,6 +116,7 @@ def capture_tool_call(
event["response"] = response
event["duration"] = duration_ms
event["is_error"] = is_error
event["error_type"] = error_type
_apply_intent(event, intent, intent_source)
if is_error:
event["error"] = capture_exception(
Expand Down Expand Up @@ -165,6 +167,7 @@ def capture_tools_list(
duration_ms: Optional[float] = None,
is_error: bool = False,
error: Any = None,
error_type: Optional[str] = None,
protocol_version: Optional[str] = None,
distinct_id: Optional[str] = None,
session_id: Optional[str] = None,
Expand All @@ -190,6 +193,7 @@ def capture_tools_list(
event["response"] = response
event["duration"] = duration_ms
event["is_error"] = is_error
event["error_type"] = error_type
if is_error:
event["error"] = capture_exception(
error if error is not None else "tools/list failed"
Expand Down
2 changes: 1 addition & 1 deletion posthog/mcp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
# plain dict (constructed and read with ``.get()`` throughout) to mirror the TS
# plain-object pipeline. Snake_case keys map to the ``$mcp_*`` wire keys in
# ``posthog_events``. Known keys: client_name, client_version, conversation_id,
# duration, error, event_name, event_type, groups, id, identify_actor_data,
# duration, error, error_type, event_name, event_type, groups, id, identify_actor_data,
# identify_actor_given_id, is_error, listed_tool_names, parameters, properties,
# resource_name, response, server_name, server_version, session_id, timestamp,
# tool_category, tool_description, user_intent, user_intent_source.
Expand Down
Loading