Privacy and redaction
Contents
MCP tool calls can carry sensitive payloads — API tokens, customer PII, raw model output. The SDK applies a layered pipeline before any event leaves your process. This page explains what's stripped automatically, what you can hook into, and what's never sent in the first place.
What never leaves your process
The SDK does not capture:
- Your PostHog API key or any environment variables.
- The transport itself (TCP/WebSocket frames, MCP framing internals).
- Tool source code, function references, or closures.
- The full content of
imageoraudiocontent blocks (replaced with a text stub). - The content of
resourceblocks with ablobpayload. - Resource bodies. A
resources/readresult is never captured;$mcp_resource_readcarries only the URI, timing, and error state. Listings (resources/list,resources/templates/list) are captured, since names, URIs, and MIME types are discovery metadata rather than content.
$mcp_tool_call payloads include $mcp_parameters (the request arguments) and $mcp_response (the tool's result), after the pipeline below.
When the TypeScript wrapper can confirm that it owns an injected analytics argument, it removes the argument before the tool handler runs. On supported high-level server paths, context, conversation_id, and llm_model don't appear in $mcp_parameters. Their accepted values are captured separately as $mcp_intent, $mcp_conversation_id, and $mcp_llm_model.
$mcp_llm_model is self-reported by the agent when captureModel is enabled. It isn't verified by the MCP protocol. Don't use it for billing, authorization, or other security decisions.
The redaction pipeline
Every event runs through these stages before being sent to PostHog:
1. Automatic sanitization
The SDK runs a deterministic sanitizer:
- Image/audio content blocks → replaced with
[image redacted: <mime>]or[audio redacted: <mime>]. - Resource blocks with a
.blob→ replaced with[resource redacted: <mime>]. - Long base64-looking strings (≥10KB) → replaced with
"[binary data redacted...]". - Keys matching the sensitive-key pattern —
authorization,cookie,password,token,secret,api_key,private_key, and similar — have their values replaced with"[redacted]". - PostHog API key patterns (
ph[a-z]_…) in any string value → replaced with"[redacted]". - Credentials inside URLs, in any string value (
$mcp_resource_name,$mcp_parameters,$mcp_response, exception messages) → theuser:password@part and the values of credential-named query and fragment fields are replaced with[redacted]. A field name counts as credential-named when any-/_/.///;-separated segment of it isauth,token,secret,password,key,signature,sig,jwt,session, and similar, plus the exact names signed URLs use (X-Amz-Signature,AWSAccessKeyId,GoogleAccessId,Policy,code). This over-redacts a benignsort_keyby design. URLs nested one level inside a retained value, authority-less URIs such asresource:guide?token=…, hash-routed fragments, and adjacent addresses run together without whitespace are all covered, and wherever a credential's boundary is ambiguous the sanitizer redacts more rather than less. A URL with nothing to redact is returned byte-for-byte; URLs over 8 KB or 128 fields are replaced whole. - Credential-looking words — detected by entropy and known key formats (
sk-…, PEM markers) — replaced word by word, in$mcp_parameters,$mcp_response, the captured intent, and exception messages. A message likeauth failed for sk-…keeps its diagnostic text and loses only the key.
This stage is not configurable, and it is a safety net rather than a general-purpose credential scrubber: it catches known key formats and obviously sensitive keys, not every secret your tools might echo. Free text your server writes — exception messages especially — is passed through as-is once those patterns are gone. If you have stricter requirements, encode them in beforeSend.
2. Truncation
After sanitization, the payload is truncated to fit within PostHog ingestion limits:
- Per-field caps applied to large strings.
- Recursive normalization: max depth 10, max breadth 100, max string 32 KB.
- A 100 KB total event budget, with progressive falloff if the budget is exceeded.
If a payload would exceed the budget, the SDK truncates rather than drops. The truncation markers are visible in the captured $mcp_parameters / $mcp_response.
3. beforeSend (optional)
beforeSend runs on each fully-built PostHog payload — { event, distinct_id, properties } — right before it's sent, once per emitted event (including the $exception sibling). It mirrors the beforeSend hook in posthog-node and may be sync or async.
- Return the (possibly mutated) event to send it.
- Return a nullish value (
null/undefined) to drop that event. - A thrown error also drops that event.
Because it runs after sanitization and truncation, anything you mutate here is the final word before the wire.
Exception autocapture
By default the SDK emits an $exception sibling event whenever a tool call fails (throws or returns isError: true). Set enableExceptionAutocapture: false to suppress that sibling — the $mcp_tool_call still records $mcp_is_error, but no $exception event is sent.
Anonymous sessions and person profiles
Events for sessions with no resolved identity are sent with $process_person_profile: false, so anonymous MCP sessions don't each mint a person profile. When identify() resolves an identity for a session, person processing stays on and events attribute to that user. See Identifying users.
Disabling capture entirely
To turn capture off without removing the wrapper, return a nullish value from beforeSend to drop every event. If you only want session/timing metadata and no payload, drop or strip the payload fields in beforeSend instead:
You own the posthog-node client's lifecycle. Call posthog.shutdown() (or posthog.flush()) from your SIGTERM / beforeExit handler — or explicitly at the end of each serverless invocation — so in-flight events aren't dropped. See Installation for the snippet.
Python
The pipeline is identical in Python — same sanitizer, same truncation caps, same $exception fan-out — and before_send is the same final say. Exception messages are sanitized and capped at 2048 characters from posthog>=7.41.
On the instrument() path it's an MCPAnalyticsOptions field. On the PostHogMCP path it's the standard posthog client kwarg, which MCP events run through like any other capture:
The callback receives the built payload — event, distinct_id, properties, timestamp — may be sync or async, and runs once per emitted event, including the $exception sibling. Return the event to send it, or None to drop it. A raised exception drops it too and is routed to the logger.
For zero payload retention, strip the payload keys and keep the timing and attribution metadata:
Set enable_exception_autocapture=False (MCPAnalyticsOptions) or mcp_exception_autocapture=False (PostHogMCP) to suppress the $exception sibling entirely.
Buffering and back-pressure
The in-memory queue is owned by the posthog-node client you pass in. If it overflows or fails to flush, events are dropped with a warning surfaced to your logger.
Logging
MCP servers commonly speak over stdio, where any write to console.* corrupts the protocol stream. The SDK defaults its logger to a no-op for that reason — wire your own in development so warnings (swallowed identify errors, dropped batches) become visible:
Errors thrown from your beforeSend, identify, intentFallback, and eventProperties callbacks are swallowed and routed to the logger — they never surface to the agent or interrupt tool execution. (A beforeSend that throws additionally drops the event it was inspecting.)