Skip to content

feat: experimental MCP analytics (PostHog::MCP) for the Ruby mcp gem - #269

Open
rafaeelaudibert wants to merge 9 commits into
mainfrom
feat/mcp-analytics
Open

feat: experimental MCP analytics (PostHog::MCP) for the Ruby mcp gem#269
rafaeelaudibert wants to merge 9 commits into
mainfrom
feat/mcp-analytics

Conversation

@rafaeelaudibert

Copy link
Copy Markdown
Member

💡 Motivation and Context

PostHog ships MCP analytics for Node (@posthog/mcp) and Python (posthog.mcp), but not Ruby. This adds PostHog::MCP, an experimental integration that wraps a server built on the official Ruby mcp gem so every tool call, handshake, listing, prompt, resource read, and failure lands in PostHog as a $mcp_* event with the same wire contract as the other two SDKs.

Setup is one line, and Rails apps that already call PostHog.init can omit the client:

require 'posthog/mcp'
PostHog::MCP.instrument(server, posthog)   # or PostHog::MCP.instrument(server) with posthog-rails

What's included:

  • PostHog::MCP.instrument with feature parity: injected context intent argument, conversation ids (prompt-back + structuredContent mirror), get_more_tools, llm_model capture, stateless Mcp-Session-Id tokens, transport identity headers, identify/$identify, before_send, event_properties, sanitization, PII redaction of intent, and 100KB truncation. Ruby also emits the prompt/resource events the other SDKs reserve.
  • PostHog::MCP::Client for custom dispatchers (no mcp gem needed) and PostHog::MCP::RackMiddleware for custom Rack stacks.
  • A private per-event _lib/_lib_version override in Client#capture. MCP events report $lib: posthog-ruby-mcp; unlike Node/Python this does not relabel the host client, so a Rails app's other events keep posthog-rails.
  • mcp added to the Gemfile test group only (peer dependency, gemspec unchanged); public API snapshot now tracks PostHog::MCP.

Design notes for reviewers:

  • The gem's documented around_request hook lacks the request, session, and params, so the integration prepends MCP::Server#handle_request and wraps the dispatch lambda it returns. That lambda's params is the same Hash the tool receives, which is what lets us strip injected arguments before the tool.call(**args) splat (an unknown keyword would otherwise become an opaque -32603). No Thread.current; the only ambient state is Fiber[] storage for the Streamable HTTP transport → server hop, because the gem re-parses the body in between.
  • Experimental: a warning is logged on require 'posthog/mcp' and on instrument (stderr outside Rails, never stdout, since stdio servers own it). README, YARD, and the changeset say so too.
  • Docs PR: PostHog/posthog.com (linked in a comment once open).

💚 How did you test it?

  • bundle exec rspec: 812 examples, 0 failures (115 new examples under spec/posthog/mcp, including end-to-end specs against a real MCP::Server over Server#handle and over StreamableHTTPTransport#call in stateless and stateful modes, a concurrency spec, and the frozen cross-SDK vectors for FNV-1a session ids and the Mcp-Session-Id token copied from the Python/JS suites).
  • bundle exec rubocop: clean. bundle exec rake public_api:check: passes.
  • examples/mcp_server.rb driven over stdio with real initialize / tools/list / tools/call JSON-RPC lines; captured events dumped to stderr.
  • Not done: sending events to a live PostHog project.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file (.changeset/quiet-ruby-mcp-analytics.md, minor)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written with Claude Code (Claude Fable 5.1) from a plan the DRI reviewed and approved. The agent explored the JS and Python MCP packages and the Ruby mcp gem source, and the DRI made the calls on scope (full parity), wiring (explicit instrument only, no Railtie auto-wiring), and $lib attribution (per-event override rather than relabeling the client). The DRI also asked for the experimental marking and for avoiding Thread.current; the hook design was reworked from an around_request + thread-local draft to the single handle_request prepend described above. Cross-SDK divergences were resolved deliberately: Python's empty-tools/list error text and code-point FNV iteration, JS's [Array] truncation marker and requested-version era check for token minting.

🤖 Generated with Claude Code

Wrap an MCP::Server so every tool call, handshake, listing, prompt, resource
read, and failure is captured as a $mcp_* event with the same wire contract as
@posthog/mcp and posthog.mcp. Ships inside posthog-ruby behind
`require 'posthog/mcp'`; the mcp gem is a peer dependency.

- PostHog::MCP.instrument(server, client = nil, **options); the client falls
  back to the posthog-rails PostHog.client facade
- Prepends MCP::Server#handle_request to strip injected arguments, time the
  call, and record results/errors without ambient state; Fiber[] storage only
  for the Streamable HTTP transport hop
- Context/intent injection, conversation ids, get_more_tools, stateless
  Mcp-Session-Id tokens, llm_model capture, identify, before_send,
  event_properties, sanitization, PII redaction, truncation
- PostHog::MCP::Client for custom dispatchers and PostHog::MCP::RackMiddleware
- Private per-event _lib/_lib_version override in Client#capture so MCP events
  report $lib posthog-ruby-mcp without relabeling the host client
- Experimental: warns on require and on instrument

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rafaeelaudibert
rafaeelaudibert requested a review from a team as a code owner September 9, 2026 02:22
@rafaeelaudibert rafaeelaudibert self-assigned this Sep 9, 2026
@rafaeelaudibert

Copy link
Copy Markdown
Member Author

Docs PR: PostHog/posthog.com#20022

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

posthog-ruby Compliance Report

Date: 2026-09-09 19:07:14 UTC
Duration: 98670ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 110ms
Format Validation.Event Has Uuid 106ms
Format Validation.Event Has Lib Properties 109ms
Format Validation.Distinct Id Is String 107ms
Format Validation.Token Is Present 106ms
Format Validation.Custom Properties Preserved 107ms
Format Validation.Event Has Timestamp 106ms
Retry Behavior.Retries On 503 5414ms
Retry Behavior.Does Not Retry On 400 2109ms
Retry Behavior.Does Not Retry On 401 2109ms
Retry Behavior.Respects Retry After Header 8115ms
Retry Behavior.Implements Backoff 15711ms
Retry Behavior.Retries On 500 5212ms
Retry Behavior.Retries On 502 5212ms
Retry Behavior.Retries On 504 5213ms
Retry Behavior.Max Retries Respected 15722ms
Deduplication.Generates Unique Uuids 112ms
Deduplication.Preserves Uuid On Retry 5211ms
Deduplication.Preserves Uuid And Timestamp On Retry 10317ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5215ms
Deduplication.No Duplicate Events In Batch 113ms
Deduplication.Different Events Have Different Uuids 108ms
Compression.Sends Gzip When Enabled 107ms
Batch Format.Uses Proper Batch Structure 106ms
Batch Format.Flush With No Events Sends Nothing 4ms
Batch Format.Multiple Events Batched Together 110ms
Error Handling.Does Not Retry On 403 2108ms
Error Handling.Does Not Retry On 413 2109ms
Error Handling.Retries On 408 5212ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 107ms
Request Payload.Flags Request Uses V2 Query Param 106ms
Request Payload.Flags Request Hits Flags Path Not Decide 106ms
Request Payload.Flags Request Omits Authorization Header 107ms
Request Payload.Token In Flags Body Matches Init 107ms
Request Payload.Groups Round Trip 106ms
Request Payload.Groups Default To Empty Object 107ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 106ms
Request Payload.Disable Geoip Omitted Defaults To False 107ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 108ms
Request Lifecycle.No Flags Request On Init Alone 4ms
Request Lifecycle.No Flags Request On Normal Capture 105ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 110ms
Request Lifecycle.Mock Response Value Is Returned To Caller 106ms
Retry Behavior.Retries Flags On 502 209ms
Retry Behavior.Retries Flags On 504 250ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 109ms

@greptile-apps

greptile-apps Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
lib/posthog/mcp/truncation.rb:24
**Payload limit drops events**

The MCP truncation budget allows events up to 102,400 bytes, but the core client rejects serialized messages over 32,768 bytes. Tool responses or parameters between those limits pass MCP truncation and are then omitted from the batch, causing valid tool-call analytics to be lost. The final payload, including any `before_send` changes, needs to fit the core message budget.

### Issue 2
lib/posthog/mcp/identity.rb:16-27
**Identity cache is unsynchronized**

`IdentityCache#get` performs an unlocked delete followed by reinsertion, while concurrent requests share this cache and both identification and event capture mutate it. Interleaved requests can temporarily lose an entry, evict the wrong entry, or attach an identity updated by another request. Protect these compound operations with the existing per-server mutex and cover concurrent identification in a test.

### Issue 3
lib/posthog/mcp/rack_middleware.rb:46-49
**Failed initialization mints sessions**

The middleware mints a session token before calling the application and adds it to every response, including rejected or failed initialization responses. A client that retains this header can replay a session created for an initialization that never succeeded. This also differs from the automatic transport, which mints only after successful dispatch. Attach the token only after confirming initialization succeeded.

---

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

Reviews (1): Last reviewed commit: "feat: add experimental PostHog::MCP anal..." | Re-trigger Greptile

Comment thread lib/posthog/mcp/truncation.rb Outdated
MAX_DEPTH = 10
MAX_BREADTH = 100
MAX_STRING_LENGTH = 32_768
MAX_EVENT_BYTES = 102_400

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 Payload limit drops events

The MCP truncation budget allows events up to 102,400 bytes, but the core client rejects serialized messages over 32,768 bytes. Tool responses or parameters between those limits pass MCP truncation and are then omitted from the batch, causing valid tool-call analytics to be lost. The final payload, including any before_send changes, needs to fit the core message budget.

Knowledge Base Used: Event capture SDK

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/posthog/mcp/truncation.rb
Line: 24

Comment:
**Payload limit drops events**

The MCP truncation budget allows events up to 102,400 bytes, but the core client rejects serialized messages over 32,768 bytes. Tool responses or parameters between those limits pass MCP truncation and are then omitted from the batch, causing valid tool-call analytics to be lost. The final payload, including any `before_send` changes, needs to fit the core message budget.

**Knowledge Base Used:** [Event capture SDK](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-ruby/-/docs/event-capture-sdk.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.

Fixed in b72f5e0. Valid: the core client drops messages over Defaults::Message::MAX_BYTES (32KB). Budget is now that minus 2KB headroom, and the size pass trims the largest strings before reducing depth. Regression spec asserts every queued message stays under the limit.

Comment on lines +16 to +27
def get(session_id)
identity = @cache.delete(session_id)
return nil if identity.nil?

@cache[session_id] = identity
end

def set(session_id, identity)
@cache.delete(session_id)
@cache.shift if @cache.length >= @max_size && !@cache.key?(session_id)
@cache[session_id] = identity
end

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.

P2 Identity cache is unsynchronized

IdentityCache#get performs an unlocked delete followed by reinsertion, while concurrent requests share this cache and both identification and event capture mutate it. Interleaved requests can temporarily lose an entry, evict the wrong entry, or attach an identity updated by another request. Protect these compound operations with the existing per-server mutex and cover concurrent identification in a test.

Knowledge Base Used: Event capture SDK

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/posthog/mcp/identity.rb
Line: 16-27

Comment:
**Identity cache is unsynchronized**

`IdentityCache#get` performs an unlocked delete followed by reinsertion, while concurrent requests share this cache and both identification and event capture mutate it. Interleaved requests can temporarily lose an entry, evict the wrong entry, or attach an identity updated by another request. Protect these compound operations with the existing per-server mutex and cover concurrent identification in a test.

**Knowledge Base Used:** [Event capture SDK](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-ruby/-/docs/event-capture-sdk.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.

Fixed in b72f5e0. IdentityCache now has its own mutex and merge! does the read-merge-write atomically; added a 16-thread concurrent identify spec.

Comment on lines +46 to +49
status, headers, body = @app.call(env)
if token && headers.respond_to?(:key?) && headers.keys.none? { |k| k.to_s.casecmp?(MCP_SESSION_HEADER) }
headers[MCP_SESSION_HEADER] = token
end

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.

P2 Failed initialization mints sessions

The middleware mints a session token before calling the application and adds it to every response, including rejected or failed initialization responses. A client that retains this header can replay a session created for an initialization that never succeeded. This also differs from the automatic transport, which mints only after successful dispatch. Attach the token only after confirming initialization succeeded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/posthog/mcp/rack_middleware.rb
Line: 46-49

Comment:
**Failed initialization mints sessions**

The middleware mints a session token before calling the application and adds it to every response, including rejected or failed initialization responses. A client that retains this header can replay a session created for an initialization that never succeeded. This also differs from the automatic transport, which mints only after successful dispatch. Attach the token only after confirming initialization succeeded.

---

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.

Fixed in b72f5e0. The middleware attaches the token only on a 2xx response and clears env['posthog_mcp.session'] otherwise; spec added.

Comment thread lib/posthog/mcp/sink.rb
@veria-ai

veria-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown

PR overview

This pull request adds experimental PostHog analytics for applications using the Ruby MCP gem, including request-scoped event capture and session attribution.

One issue has been addressed, but a request-scope propagation problem remains on Ruby 3.0 and 3.1. During concurrent requests, child fiber or thread work can fall back to shared session data, allowing an attacker to cause another user’s event properties to be attributed to the attacker’s PostHog session and identity. The impact is limited to analytics attribution, but the remaining race should be fixed before relying on this integration in concurrent production workloads.

Open issues (1)

Fixed/addressed: 1 · PR risk: 5/10

- Budget truncation under the core client's 32KB per-message limit (which
  drops larger messages at batch time) and trim the largest strings before
  reducing depth so big tool responses keep their shape
- Make IdentityCache thread-safe and merge identities atomically
- RackMiddleware attaches the minted Mcp-Session-Id only on a successful
  initialize response

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread lib/posthog/mcp/README.md
@@ -0,0 +1,116 @@
# PostHog MCP analytics for Ruby

> **Experimental.** `PostHog::MCP` is new and its API, options, and the captured `$mcp_*` event schema may change in a minor release. A one-line warning is logged when you require it. Please report issues at https://github.com/PostHog/posthog-ruby/issues.

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.

lets remove all the code snippets and event/prop names from this file to https://posthog.com/docs/mcp-analytics so its a single source of truth

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.

While this is experimental I thought it made sense to keep this here as source of truth. Eventually once the MCP Analytics starts owning this then they can probably own moving this. Are you ok with that?

@marandaneto
marandaneto requested review from a team September 9, 2026 08:32
@marandaneto

Copy link
Copy Markdown
Member

@PostHog/mcp-analytics is there any demand for this (i've not seen any request for it)? its one more package to maintain so take that into consideration

@marandaneto marandaneto left a comment

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.

Advisory review of the complete PR patch at b72f5e0. These seven findings were reproduced with focused regression tests in disposable worktrees. The existing MCP and core-client suite passed (250 examples). Validation used Ruby 4.0.6, MCP 1.5.0, and available dependency versions rather than the complete locked bundle; temporary controls were not retained as production fixes.

Comment thread lib/posthog/mcp/analytics.rb Outdated
return if data.nil?

Instrumentation.capture_event(data, {
'session_id' => data.session_id,

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: Scope custom events to the active request. capture reads server-global data.session_id, but the active request's session is prepared only after its tool returns. After Alice and then Bob initialize, a custom event emitted inside Alice's next tool call is attributed to Bob—even without concurrency. Retain request-local session and identity instead of using the last completed request's state. Reproduction: reproduced — the focused review_instrumentation_regression_spec.rb RSpec test received custom-event distinct ID bob while the corresponding automatic tool event correctly used alice.

@rafaeelaudibert rafaeelaudibert Sep 9, 2026

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.

Fixed by e8f93cb.

Confirmed, and it did not need concurrency to trigger: dispatch_tool_call only resolved the session in prepare_request after the handler returned (the conversation anchor is only known then), so during Alice's tool body data.session_id still pointed at whoever finished last. Two layers in the fix:

  1. Prime before the tool body. Instrumentation#prime_session resolves the transport/token session onto data.session_id before yield. Emits nothing; the post-call prepare_request still owns identify, lazy initialize and the conversation anchor.
  2. Request-local read. The primed session is pinned to the in-flight RequestScope (the fiber-storage scope the transport already opens per HTTP request), and Analytics#capture reads it from there first, falling back to data.session_id only when there is no scope (stdio, single client). That covers overlapping requests on a threaded server too, which priming alone does not.

Specs in http_transport_spec.rb cover the sequential case you reproduced and an overlapping one (Alice's tool body lets Bob's whole request finish before it captures).

Heads-up for @posthog/mcp (Node). It has half of this. captureToolCall runs prepareToolCallEvent (which resolves the session and writes data.sessionId) before execute(downstreamRequest), so the sequential Alice/Bob case is attributed correctly there. What it is missing is the second layer: captureCustomEvent in packages/mcp/src/index.ts reads trackingData.sessionId, one slot per server, and tool handlers are async. If Alice's handler awaits anything while Bob's request comes in and re-primes data.sessionId, Alice's analytics.capture(...) after the await is attributed to Bob. There is no AsyncLocalStorage (or equivalent request-local store) in the package today. The equivalent fix is to open an AsyncLocalStorage store per request in the request-handler patch, stash the primed sessionId in it, and have captureCustomEvent read from the store before falling back to trackingData.sessionId. Worth an issue on posthog-js.

if @options.report_missing && name == missing_name && !real_tool?(name)
result = Tools.result
safely { record_missing_capability(name, original_arguments, request) }
return result

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: Preserve MCP's lifecycle for virtual tool calls. This early return skips the underlying handler lambda, including its protocol-envelope validation and cancellation cleanup. MCP has already registered the stateful request's cancellation object, so every completed get_more_tools call retains an in-flight entry indefinitely; malformed modern-protocol requests also incorrectly succeed. The virtual response needs to run within the real request lifecycle. Reproduction: reproduced — focused MCP 1.5.0 transport tests retained three cancellation entries after three completed virtual calls and returned HTTP 200 for a malformed virtual call whose ordinary-tool control returned HTTP 400/-32602.

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.

Fixed by 6ccd422.

instrument now registers get_more_tools on the server with define_tool (skipped when the application already owns the name, which then behaves as an ordinary tool). The gem dispatches it like any other tool, so argument validation, envelope checks and the in-flight cancellation cleanup all run; the instrumentation only swaps the recorded event for $mcp_missing_capability. The manual tools/list append is gone since the gem lists it itself. Specs: stateful HTTP spec asserts no in-flight entries remain after three virtual calls, and a call without context now gets the gem's Missing required arguments error response while still recording the missing capability.

return sanitized unless sanitized.is_a?(Hash)

result = sanitized.dup
if result['content'].is_a?(Array)

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: Redact binary prompt and resource response shapes too. Only top-level tool-result content arrays reach sanitize_content_block. Automatically captured prompt results instead use messages[].content, and resource reads use contents[].blob; these receive only generic string sanitization, so binary payloads below the 10KB heuristic threshold reach $mcp_response unchanged despite the documented binary-content redaction. Reproduction: reproduced — the focused review_payloads_spec.rb RSpec test found the original blob c2Vuc2l0aXZl in both $mcp_prompt_get and $mcp_resource_read payloads returned by the actual sink.

Comment thread lib/posthog/mcp/sanitization.rb Outdated
end

def decode_percent(value)
URI.decode_www_form_component(value)

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: Preserve literal plus signs when decoding base64 data URLs. URI.decode_www_form_component converts literal + into spaces, but + is a valid standard-base64 character in a data URL. Valid binary URLs then fail detection and can be transmitted unchanged. Use percent-decoding that preserves literal plus signs. Reproduction: reproduced — the focused review_payloads_spec.rb RSpec test passed data:application/octet-stream;base64, followed by '++//' * 3000 through the sink and received the complete URL in $mcp_parameters instead of the binary placeholder.

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.

Fixed by 6ca3657.

decode_percent uses URI.decode_uri_component (percent-only) with URI::DEFAULT_PARSER.unescape as the fallback on Ruby 3.0/3.1. Spec covers your '++//' * 3000 payload and a percent-encoded plus.

].freeze

NORMALIZED_FIELDS = %w[parameters response identify_actor_data error].freeze

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: Include custom properties in bounded normalization. User-controlled properties from custom events, event_properties, and capture_tool_call are excluded from normalization and progressive depth reduction. Large numeric arrays cannot be reduced by largest-string trimming, so oversized events survive this pipeline and are then discarded by the core batch despite the message-size guarantee. Reproduction: reproduced — the focused review_payloads_spec.rb RSpec test captured properties: { rows: (1..10_000).to_a }, produced a 49,288-byte message, and observed PostHog::MessageBatch retain zero messages instead of one.

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.

Fixed by bbcc662.

properties is now part of NORMALIZED_FIELDS, so custom event properties, event_properties and capture_tool_call properties get breadth/depth normalization and take part in progressive reduction. Spec reproduces the 10k-row array and asserts the event lands under MAX_EVENT_BYTES.

Comment thread lib/posthog/mcp/sanitization.rb Outdated
# Deep-copies a value with string keys so the pipeline can rely on one shape.
def stringify_keys(value)
case value
when Hash then value.to_h { |k, v| [k.to_s, stringify_keys(v)] }

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: Detect cycles before recursively copying telemetry. Ruby custom properties can contain a self-reference, but this traversal runs before the cycle-aware normalizer and recurses indefinitely. The resulting SystemStackError is not caught by the sink's StandardError rescue, so analytics crashes through into host code. This concerns Ruby custom properties, not oversized or cyclic JSON request bodies. Reproduction: reproduced — the focused review_payloads_spec.rb RSpec test supplied properties['self'] = properties and observed SystemStackError escaping the public capture_tool_call method before core-client dispatch.

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.

Fixed by 3615dea.

stringify_keys tracks visited containers by identity and emits the same [Circular ~] marker the normalizer uses (shared, non-cyclic references are still copied). Client#capture_tool_call with a self-referential properties hash no longer raises; covered in client_spec and sanitization_spec.

Comment thread lib/posthog/mcp/client.rb Outdated
raw_context = args.is_a?(Hash) ? (args[:context] || args['context']) : nil
intent = raw_context.is_a?(String) && !raw_context.strip.empty? ? raw_context.strip : nil
PreparedToolCall.new(
args: strip_context(args),

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: Preserve tool-owned context arguments in custom dispatchers. prepare_tool_list leaves a tool's existing context schema intact, but prepare_tool_call unconditionally removes that argument. A tool requiring its own context consequently receives incomplete arguments. Track which arguments the integration actually injected and strip only those. Reproduction: reproduced — the focused review_custom_dispatch_spec.rb RSpec test prepared a tool with its own required context field and expected { context: 'application data' } after call preparation but received {}; an ownership-aware temporary control passed.

@rafaeelaudibert rafaeelaudibert Sep 9, 2026

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.

Fixed by 5f135d3.

prepare_tool_call takes an optional input_schema: (the tool's raw inputSchema, the same Hash handed to prepare_tool_list). When the schema declares context, the argument is left in args; otherwise it is the one we injected and it is stripped as before. Intent extraction is unchanged either way. Custom dispatchers are stateless across pods, so asking for the schema at call time is the only way to decide ownership reliably; remembering what prepare_tool_list injected would only be right on the pod that served the listing. Without the keyword the behaviour is unchanged. README updated, spec matches your reproduction.

@marandaneto
marandaneto requested a review from a team September 9, 2026 08:47
@marandaneto

Copy link
Copy Markdown
Member

left a few comments but @PostHog/mcp-analytics should know more about the specifics

rafaeelaudibert and others added 2 commits September 9, 2026 15:37
`Analytics#capture` read the server-wide `data.session_id`, which the tool
call only settled *after* its handler returned. A custom event emitted from
inside a tool body therefore carried whichever session the previous request
left behind, and with it that caller's identity.

Two layers:
- prime the session before the tool body runs (session id only; the
  conversation anchor is still resolved after the call)
- pin the primed session to the in-flight `RequestScope`, and have `capture`
  read it from there first, so overlapping requests on a threaded server
  cannot cross-attribute either

Specs cover both the sequential and the overlapping Alice/Bob case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… in comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# tool body, so a custom event captured inside the tool is attributed to this
# request even while another request on the same server is in flight.
def current
FIBER_STORAGE ? Fiber[KEY] : Thread.current[KEY]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Low: Child work loses the request scope on Ruby 3.0 and 3.1

Thread.current[] is fiber-local and is not inherited by a tool-created fiber or thread. In that child, Analytics#capture falls back to the shared data.session_id, so an attacker racing another request can cause one user's custom event properties to be recorded under the attacker's session and PostHog identity. Propagate an immutable request context explicitly to child work, or fail closed instead of using the server-wide session when an HTTP capture has no request scope.

@rafaeelaudibert

Copy link
Copy Markdown
Member Author

@PostHog/mcp-analytics is there any demand for this (i've not seen any request for it)? its one more package to maintain so take that into consideration

@marandaneto I didn't hear any demand from them, but I have my side projects in Rails and I want to instrument them with MCP Analytics, so I built this for myself. I 100% believe we should not merge this if the team is not interested in maintaining it, because I don't plan on maintaining all this myself. I can get the same by doing a point implementation just for my server, too.

@marandaneto

Copy link
Copy Markdown
Member

@PostHog/mcp-analytics is there any demand for this (i've not seen any request for it)? its one more package to maintain so take that into consideration

@marandaneto I didn't hear any demand from them, but I have my side projects in Rails and I want to instrument them with MCP Analytics, so I built this for myself. I 100% believe we should not merge this if the team is not interested in maintaining it, because I don't plan on maintaining all this myself. I can get the same by doing a point implementation just for my server, too.

i mean all the work is done so i think we should merge and release it but i'd like to bring this up because i heard the same from the AIO team, they are too thin to bring support for more SDKs (or just maintain it)
its better to have the most important ones well maintained than a bunch not really

rafaeelaudibert and others added 5 commits September 9, 2026 15:51
`URI.decode_www_form_component` is form decoding and turns `+` into a
space, but `+` is a standard base64 character. Valid binary data URLs
therefore failed detection and were captured verbatim. Use percent-only
decoding instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Custom event properties, `event_properties` and `capture_tool_call`
properties skipped normalization and progressive depth reduction. A large
numeric array cannot be shrunk by string trimming, so such events stayed
oversized and the core client dropped them at batch time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`stringify_keys` runs before the cycle-aware normalizer and recursed
forever on self-referential custom properties. The resulting
`SystemStackError` is not a `StandardError`, so it escaped the sink into
host code. Track visited containers and emit the same `[Circular ~]`
marker the normalizer uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The virtual tool used to short-circuit the gem's dispatch lambda, which
skipped envelope validation and leaked the in-flight cancellation entry the
gem registers before handing us the lambda. `instrument` now defines the
tool on the server (unless the application already owns the name), the gem
dispatches it like any other tool, and the instrumentation only swaps the
recorded event for `$mcp_missing_capability`. The manual `tools/list`
append goes away since the gem lists the tool itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`prepare_tool_list` leaves a `context` field the tool declares itself
intact, but `prepare_tool_call` stripped the argument unconditionally, so
such a tool received incomplete arguments. `prepare_tool_call` now takes an
optional `input_schema:` and strips `context` only when the schema does not
declare it. Without the schema the behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lucasheriques

Copy link
Copy Markdown

i'll review this for the new spec soon, but as @marandaneto pointed, we on the MCP Analytics team do not have the capability to support this SDK for now.

i think we can merge this though - work is done as Manoel pointed, but maybe on the docs, we say it's not an official solution and we don't provide support for it yet.

@lucasheriques lucasheriques left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

Replaced this long report with nine concise inline comments and a short summary.

@lucasheriques lucasheriques left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

Recommend changes before merging. See the nine inline findings on MCP compatibility and SDK parity.

tool = tools.is_a?(Hash) ? tools[name] : nil
schema = tool.respond_to?(:input_schema) ? tool.input_schema&.to_h : nil
owned = []
owned << 'context' if @options.context_enabled? && !SchemaMutation.declares_param?(schema, 'context')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P1] Preserve composed/reference schemas

Before tools/list, a tool whose allOf schema requires application-owned context loses that argument and fails validation; the identical call succeeds after listing. Use one conservative injection/ownership guard, including $ref: injecting required context beside a reference to a closed object also makes discovery unsatisfiable. The standalone prepare_tool_call has the same composed-ownership problem.

return sanitized unless sanitized.is_a?(Hash)

result = sanitized.dup
if result['content'].is_a?(Array)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P1] Redact binary prompt/resource responses

Only top-level content blocks are redacted. Real prompts/get and resources/read calls still capture short binary payloads from messages[].content.data and contents[].blob in $mcp_response. Extend shape-aware redaction to those response forms while preserving client-facing results. This reproduces the existing unresolved finding.

event['response'] = result
if tool_result_error?(result)
event['is_error'] = true
event['error'] = Exceptions.capture_exception(Sanitization.stringify_keys(result))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Extract errors before adding conversation metadata

With enable_conversation_id, identical failures become same failure {"conversation_id":"<fresh UUID>"} in $mcp_error_message, fragmenting error grouping. Extract errors from the original result before appending the conversation handle. Current TS already does this.

# full {#prepare_request} still runs after the call, because the conversation
# anchor is only known once the tool has returned. Emits nothing.
def prime_session
session_id, = Session.resolve(@data, mcp_session_id(@token), token: @token)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Resolve identity and echoed conversation before capture

On a first modern request with a valid echoed conversation ID, analytics.capture inside the tool uses an anonymous, temporary session; the automatic tool event uses the identified person and conversation session. Resolve and pin both identity and the already-known conversation anchor before invoking the tool.

return tool unless tool.is_a?(Hash)

name = fetch(tool, :name)
return tool if virtual_tool?(name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Complete model capture for missing capabilities

With capture_model: true, get_more_tools neither advertises llm_model nor records $mcp_llm_model when supplied. Its recorder and the standalone client's preparation/missing-capability helpers need the model support already present in current Python and TS.

status, headers, body = @app.call(env)
# Only a successful initialize gets the token, so a client cannot replay a
# session minted for a handshake the server rejected.
if token && success?(status) && headers.respond_to?(:key?) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Check initialization success beyond HTTP status

An HTTP 200 response containing a JSON-RPC error still gets a new mcp-session-id. The legacy transport contract associates that header with InitializeResult. Require a successful initialization result or an explicit success signal from the dispatcher before attaching the token.

'session_id' => session_id,
'resource_name' => request_resource_name(request),
'event_type' => EventType::IDENTIFY,
'parameters' => { 'request' => request, 'extra' => captured_extra(extra) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Remove raw intent from identify parameters

With identify enabled, context: "Look up jane@example.com" is redacted on $mcp_tool_call but survives verbatim in $identify under $mcp_parameters.request.params.arguments.context. Use the captured-parameter builder here too. Python/TS share this privacy gap; parity alone would preserve it.

Comment thread public_api_snapshot.txt
instance_method PostHog::MCP::Client#capture_missing_capability(context: ..., parameters: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ...)
instance_method PostHog::MCP::Client#capture_tool_call(tool_name, intent: ..., intent_source: ..., parameters: ..., response: ..., duration_ms: ..., is_error: ..., error: ..., error_type: ..., category: ..., tool_description: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ..., llm_model: ..., llm_model_source: ...)
instance_method PostHog::MCP::Client#capture_tools_list(tool_names: ..., parameters: ..., response: ..., duration_ms: ..., is_error: ..., error: ..., error_type: ..., protocol_version: ..., distinct_id: ..., session_id: ..., client_user_agent: ..., vendor_client: ..., set_properties: ..., groups: ..., properties: ..., timestamp: ...)
instance_method PostHog::MCP::Client#prepare_tool_call(name, args = ...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Refresh the public API snapshot

prepare_tool_call now accepts input_schema:, but this snapshot omits it. bundle exec rake public_api:check fails locally and in CI. Regenerate the snapshot after settling the API changes.

properties_key = key_for(schema, :properties)
schema[properties_key] = {} unless schema[properties_key].is_a?(Hash)
additional_key = key_for(schema, :additionalProperties)
schema.delete(additional_key) if schema[additional_key] == false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

🤖 Automated comment by QA Swarm — not written by a human

[P2] Preserve additionalProperties: false

For a strict tool declaring x, {x: "yes", context: "why", typo: 123} passes the advertised schema but dispatch rejects /typo. The injected property is already allowed through properties; keep additionalProperties: false so discovery matches validation. Python preserves it; TS shares this bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants