Skip to content

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

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

rafaeelaudibert wants to merge 11 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-11 18:52:57 UTC
Duration: 98382ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 10ms
Format Validation.Event Has Uuid 105ms
Format Validation.Event Has Lib Properties 109ms
Format Validation.Distinct Id Is String 106ms
Format Validation.Token Is Present 107ms
Format Validation.Custom Properties Preserved 107ms
Format Validation.Event Has Timestamp 107ms
Retry Behavior.Retries On 503 5315ms
Retry Behavior.Does Not Retry On 400 2108ms
Retry Behavior.Does Not Retry On 401 2109ms
Retry Behavior.Respects Retry After Header 8117ms
Retry Behavior.Implements Backoff 15722ms
Retry Behavior.Retries On 500 5213ms
Retry Behavior.Retries On 502 5212ms
Retry Behavior.Retries On 504 5212ms
Retry Behavior.Max Retries Respected 15522ms
Deduplication.Generates Unique Uuids 111ms
Deduplication.Preserves Uuid On Retry 5211ms
Deduplication.Preserves Uuid And Timestamp On Retry 10412ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5217ms
Deduplication.No Duplicate Events In Batch 112ms
Deduplication.Different Events Have Different Uuids 107ms
Compression.Sends Gzip When Enabled 106ms
Batch Format.Uses Proper Batch Structure 107ms
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 107ms
Request Payload.Flags Request Hits Flags Path Not Decide 106ms
Request Payload.Flags Request Omits Authorization Header 108ms
Request Payload.Token In Flags Body Matches Init 108ms
Request Payload.Groups Round Trip 108ms
Request Payload.Groups Default To Empty Object 107ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 107ms
Request Payload.Disable Geoip Omitted Defaults To False 107ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 107ms
Request Lifecycle.No Flags Request On Init Alone 3ms
Request Lifecycle.No Flags Request On Normal Capture 105ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 111ms
Request Lifecycle.Mock Response Value Is Returned To Caller 106ms
Retry Behavior.Retries Flags On 502 209ms
Retry Behavior.Retries Flags On 504 246ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 108ms

@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
Comment thread lib/posthog/mcp/identity.rb
Comment thread lib/posthog/mcp/rack_middleware.rb Outdated
Comment thread lib/posthog/mcp/sink.rb
@veria-ai

veria-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 3 · PR risk: 0/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 Outdated
@@ -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?

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.

well fine by me but mcp in general is experimental https://github.com/PostHog/posthog-js/blob/f998ee9c0010d94f3fa0f33cccfd57f55e792e7e/packages/mcp/package.json#L3
and it lives in posthog.com
same for python, so i dont see a reason not to

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.

Done in f6bf9f0 — you're right, and posthog.com is now the single source of truth. lib/posthog/mcp/README.md keeps no code snippets or event/property names: just what the integration is, a link to https://posthog.com/docs/mcp-analytics, the handful of genuinely Ruby-specific notes (per-event $lib, the tighter 32KB truncation budget, request scope needing Ruby 3.2+ to be inherited, composed schemas left untouched, stdio logging), and a file map for anyone reading the code.

While I was there I also took @lucasheriques' point about support: the README banner, the require-time warning, the changeset and the example now say the integration is experimental and not officially supported, rather than implying it's supported via issues. Same on the docs side — PostHog/posthog.com#20022 now says the MCP analytics team doesn't maintain it and points at TypeScript or Python for a supported SDK.

@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
Comment thread lib/posthog/mcp/instrumentation.rb
Comment thread lib/posthog/mcp/sanitization.rb Outdated
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.

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 bf75806sanitize_response is now shape-aware for messages[].content (single block or array) and contents[].blob, so a sub-10KB blob is redacted there too. Covered end to end in instrument_spec.rb ("redacts binary prompt messages and resource blobs on the way out"), which asserts the original blob appears nowhere in the emitted payloads.

Comment thread lib/posthog/mcp/sanitization.rb Outdated
Comment thread lib/posthog/mcp/truncation.rb
Comment thread lib/posthog/mcp/sanitization.rb Outdated
Comment thread lib/posthog/mcp/client.rb Outdated
@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>
Comment thread lib/posthog/mcp/request_scope.rb
@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.

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 bf75806 — one guard, SchemaMutation.injectable? (not composed, no $ref), is now used by add_parameter, Instrumentation#owned_params_for and Client#prepare_tool_call. A composed or referenced schema is neither injected into nor stripped from, so a call before the first tools/list behaves exactly like one after it.

Comment thread lib/posthog/mcp/sanitization.rb Outdated
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.

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 bf75806 — same fix as the blocking thread above: messages[].content and contents[].blob now go through the shape-aware redaction, with an end-to-end spec over real prompts/get and resources/read calls.

Comment thread lib/posthog/mcp/instrumentation.rb Outdated
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.

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 bf75806 — the error is now extracted from the result the tool returned, before the conversation handle is appended, so identical failures keep an identical $mcp_error_message. The handle still reaches the agent on the delivered result.

Comment thread lib/posthog/mcp/instrumentation.rb Outdated
# 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.

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 bf75806 — session and identity are now settled before the tool body runs, anchored on the echoed conversation_id when the agent sent one. Non-HTTP transports open a request scope too, so the pin works over stdio as well, and identify still runs once per session per request.

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.

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 bf75806get_more_tools advertises llm_model under capture_model and records $mcp_llm_model/$mcp_llm_model_source, in the instrumented path and in Client#prepare_tool_list / #capture_missing_capability. conversation_id is deliberately left off the virtual tool: it reports a gap in the tool list rather than taking part in a tool conversation.

Comment thread lib/posthog/mcp/rack_middleware.rb Outdated
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.

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 bf75806 — the middleware now requires an InitializeResult before attaching Mcp-Session-Id, so a JSON-RPC error on a 200 mints nothing. A body that cannot be read without consuming a stream (SSE) keeps the status-only behaviour, which is documented on the method.

'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.

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.

Not changing this one: we want the same behaviour across the SDKs here, so this stays as it is rather than diverging in Ruby alone. Worth raising as a cross-SDK issue if we want the captured-parameter builder used for $identify everywhere.

Comment thread public_api_snapshot.txt Outdated
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.

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 bf75806 — regenerated after the API changes settled; bundle exec rake public_api:check passes.

Comment thread lib/posthog/mcp/schema_mutation.rb Outdated
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.

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 bf75806additionalProperties: false is preserved; the injected name is listed under properties, so it is still accepted, and the spec that asserted the removal was inverted.

- Redact binary payloads in `prompts/get` `messages[].content` and
  `resources/read` `contents[].blob`; only tool-result `content` blocks were
  shape-aware, so a short blob reached `$mcp_response` verbatim.
- Never inject into, or claim ownership of, a composed (oneOf/allOf/anyOf) or
  `$ref` input schema. A tool declaring `context` inside `allOf` used to lose
  the argument when called before the first `tools/list`. One guard,
  `SchemaMutation.injectable?`, now covers listing, dispatch and
  `Client#prepare_tool_call`.
- Keep `additionalProperties: false` when injecting: the injected name is
  listed under `properties`, so dropping the constraint only advertised a
  looser schema than the dispatcher validates against.
- Extract a failed tool result's error from the result the tool returned,
  before the conversation handle is appended, so identical failures keep an
  identical `$mcp_error_message` instead of one per conversation.
- Settle session and identity before the tool body runs, anchored on an echoed
  `conversation_id` when the agent sent one, so a custom event captured inside
  a tool carries the same `$session_id` and person as its `$mcp_tool_call`.
  Non-HTTP transports now open a request scope too, and `identify` still runs
  once per session per request.
- Fail closed when an in-tool capture has lost its request scope on a server
  that has served HTTP: use a standalone session rather than the server-wide
  one, which may belong to a concurrent request.
- Advertise and record `llm_model` on `get_more_tools` under `capture_model`,
  in the instrumented path and in `Client#prepare_tool_list` /
  `#capture_missing_capability`.
- `RackMiddleware` requires an `InitializeResult` before attaching
  `Mcp-Session-Id`: a JSON-RPC error can ride on a 200. A body that cannot be
  sniffed without consuming a stream keeps the status-only behaviour.
- Regenerate the public API snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rafaeelaudibert

Copy link
Copy Markdown
Member Author

Pushed bf75806, which addresses the outstanding review threads. Summary of what changed and how:

Thread Fix
Binary prompt/resource responses not redacted (blocking, + the QA Swarm duplicate) sanitize_response is shape-aware for prompts/get messages[].content (single block or array) and resources/read contents[].blob, not just tool-result content. End-to-end spec asserts the blob appears nowhere in the emitted payloads.
Request scope lost in child fibers/threads on Ruby 3.0/3.1 Fails closed: once a request has arrived over HTTP, a capture with no request scope gets a standalone $session_id plus a one-time warning instead of the server-wide session. Over stdio the fallback stays.
Composed/$ref schemas lose a tool-owned context One guard, SchemaMutation.injectable?, used by add_parameter, Instrumentation#owned_params_for and Client#prepare_tool_call: a composed or referenced schema is neither injected into nor stripped from.
Conversation handle fragments error grouping The error is extracted from the result the tool returned, before the handle is appended. The handle still reaches the agent on the delivered result.
In-tool events use an anonymous, wrong session Session and identity settle before the tool body runs, anchored on an echoed conversation_id. Non-HTTP transports now open a request scope too, and identify still runs once per session per request.
get_more_tools missing model capture The virtual tool advertises llm_model under capture_model and records $mcp_llm_model; Client#prepare_tool_list and #capture_missing_capability gained the same support. conversation_id is deliberately left off the virtual tool.
Token minted for a failed initialize RackMiddleware requires an InitializeResult before attaching Mcp-Session-Id; a body that cannot be sniffed without consuming a stream (SSE) keeps the status-only behaviour.
additionalProperties: false dropped Preserved — the injected name is listed under properties, so it is still accepted. The spec that asserted the removal was inverted.
Public API snapshot stale Regenerated; rake public_api:check passes.

Two threads intentionally unchanged:

  • Raw intent in $identify parameters — keeping the behaviour consistent across the SDKs rather than diverging in Ruby alone; better raised as a cross-SDK issue.
  • Moving the README content to the docs site — replied in-thread, waiting on @marandaneto.

bundle exec rspec (839 examples) and rubocop are clean.

Comment thread lib/posthog/mcp/rack_middleware.rb Outdated
The Rack middleware sniffed the JSON-RPC request body to spot an
`initialize`, and the response body to check the handshake succeeded. On a
non-rewindable `rack.input` the bounded read was followed by a lengthless
one, so a large POST was buffered whole and MAX_SNIFF_BODY stopped being a
memory bound.

Nothing is parsed now. The middleware publishes the request headers to
RequestScope as an HTTP request, so an instrumented server dispatched below
it mints exactly as it does under the gem's own transport - after the
handshake produced an InitializeResult, which is a stronger signal than
sniffing a 200 for a `result` key, and works for SSE bodies too. A
hand-rolled dispatcher, which has already parsed the body, mints through
`env['posthog_mcp.mint']`.

Docs move to posthog.com/docs/mcp-analytics as the single source of truth;
the README keeps only the Ruby-specific notes. The experimental notice, the
README, the changeset, and the example now all say the integration is
unsupported, not just experimental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

posthog-ruby-async Compliance Report

Date: 2026-09-15T22:21:18.174318+00:00
Duration: 98170ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

30/30 tests passed

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

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

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

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

@github-actions

Copy link
Copy Markdown

posthog-ruby-sync Compliance Report

Date: 2026-09-15T22:21:18.625793+00:00
Duration: 93980ms

⚠️ Some Tests Failed

45/47 tests passed, 2 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 11ms
Format Validation.Event Has Uuid 7ms
Format Validation.Event Has Lib Properties 7ms
Format Validation.Distinct Id Is String 11ms
Format Validation.Token Is Present 8ms
Format Validation.Custom Properties Preserved 8ms
Format Validation.Event Has Timestamp 9ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc 8ms
Retry Behavior.Retries On 503 5305ms
Retry Behavior.Does Not Retry On 400 2011ms
Retry Behavior.Does Not Retry On 401 2010ms
Retry Behavior.Respects Retry After Header 8018ms
Retry Behavior.Implements Backoff 15256ms
Retry Behavior.Retries On 500 5117ms
Retry Behavior.Retries On 502 5158ms
Retry Behavior.Retries On 504 5117ms
Retry Behavior.Max Retries Respected 15515ms
Deduplication.Generates Unique Uuids 24ms
Deduplication.Preserves Uuid On Retry 5164ms
Deduplication.Preserves Uuid And Timestamp On Retry 10248ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5118ms
Deduplication.No Duplicate Events In Batch 19ms
Deduplication.Different Events Have Different Uuids 9ms
Compression.Sends Gzip When Enabled 6ms
Batch Format.Uses Proper Batch Structure 8ms
Batch Format.Flush With No Events Sends Nothing 3ms
Batch Format.Multiple Events Batched Together 18ms
Error Handling.Does Not Retry On 403 2008ms
Error Handling.Does Not Retry On 413 2008ms
Error Handling.Retries On 408 5117ms

Failures

batch_format.multiple_events_batched_together

Expected 1 requests, got 5

Feature_Flags Tests

⚠️ 16/17 tests passed, 1 failed

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

Failures

request_payload.disable_geoip_omitted_defaults_to_false

Field 'geoip_disable' not found in /flags request body at path 'geoip_disable'. Available keys: ['distinct_id', 'groups', 'person_properties', 'group_properties', 'flag_keys_to_evaluate', 'token']

@marandaneto

Copy link
Copy Markdown
Member

lgtm
i'll let @PostHog/mcp-analytics hit the approval button if they are fine with it

@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.

I reviewed the Ruby MCP integration against the current JavaScript and Python SDKs and ran the Ruby test suite. I left five inline findings for discussion.

return nil if sink.nil?

session_id = input['session_id'] || data.session_id
actor = session_id ? data.identified_sessions.get(session_id) : nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I found a race in identity attribution. capture_event reads the shared identity cache after the tool finishes. Another call in the same session can change it first. In a two-thread test, Alice's call waited while Bob's finished, and Alice's $mcp_tool_call used Bob's distinct_id.

The JavaScript SDK keeps the resolved identity with each request. Please keep that request identity for automatic and custom events, and add a concurrent regression test.

Comment thread lib/posthog/mcp/client.rb
# @param args [Hash, nil] the call's arguments
# @param input_schema [Hash, nil] the tool's raw `inputSchema`
# @return [PreparedToolCall]
def prepare_tool_call(name, args = nil, input_schema: nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The custom-dispatcher helpers do not round-trip llm_model yet. prepare_tool_list(..., capture_model: true) adds it as a required argument, but prepare_tool_call leaves it in args and does not return the model or its source. I passed those args to a strict Ruby tool and got ArgumentError: unknown keyword: :llm_model.

JavaScript and Python remove SDK-owned model fields and return the model metadata. Please add the same handling here, with a round-trip test.

Comment thread lib/posthog/mcp/sink.rb

autocapture = options.nil? || options.enable_exception_autocapture
payloads = EventBuilder.build(processed, enable_exception_autocapture: autocapture)
apply_before_send(payloads, options)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An expanding before_send hook can cause a silent event drop. The MCP size check runs before the hook, while MessageBatch applies Ruby's 32 KB limit afterward. A hook that added 40 KB made the sink return one payload, but the transport sent zero requests.

Please enforce the final size limit after before_send and test this case.

missing_name = Tools.missing_capability_tool_name(@options)
owned = safely([]) { owned_params_for(name) }
stripped = safely({}) { strip_injected_arguments(arguments, owned) }
conversation_id, minted = safely([nil, false]) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The ownership check is in place, but intent and conversation resolution still read application-owned fields from original_arguments. I saw a tool-owned context become $mcp_intent. With two users sharing a tool-owned UUIDv7 conversation_id, Bob's event also included Alice's email in $set.

The JavaScript SDK consumes these values only when analytics owns them. Please use the ownership result for both fields and add tests for tools that declare them.

end

def maybe_emit_initialize(session_id, request)
return if @data.session_initialized?(session_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The initialize check and mark are locked separately, but the full step is not atomic. Two threads can pass the check before either marks the session. I reproduced two $mcp_initialize events for one session.

Please make check-and-mark one atomic operation and add a concurrent test.

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