posthog-ruby 3.23.8 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Resolve `$mcp_intent` from the agent-supplied `context` argument (source
6
+ # `context_parameter`) or the customer's `intent_fallback` (source `inferred`).
7
+ #
8
+ # @api private
9
+ module Intent
10
+ module_function
11
+
12
+ def normalize(intent)
13
+ return nil unless intent.is_a?(String)
14
+
15
+ trimmed = intent.strip
16
+ trimmed.empty? ? nil : trimmed
17
+ end
18
+
19
+ # @param context [String, nil] the `context` argument, and only when this
20
+ # layer owns it. A tool that declares `context` in its own schema is
21
+ # passed application data, which is never the agent's stated intent.
22
+ # @return [Array(String, String), nil] `[intent, source]`
23
+ def resolve(data, request, extra, context = nil)
24
+ params = request[:params] || request['params'] || {}
25
+ name = params[:name] || params['name']
26
+ missing_name = Tools.missing_capability_tool_name(data.options)
27
+ intent = normalize(context)
28
+ return [intent, 'context_parameter'] if data.options.context_enabled? && name != missing_name && intent
29
+
30
+ run_fallback(data, request, extra)
31
+ end
32
+
33
+ def run_fallback(data, request, extra)
34
+ fallback = data.options.intent_fallback
35
+ return nil unless fallback
36
+
37
+ intent = normalize(Callbacks.call(fallback, request, extra))
38
+ intent ? [intent, 'inferred'] : nil
39
+ rescue StandardError => e
40
+ Log.debug(data.options, "intent_fallback callback error: #{e.message}")
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Model capture (`capture_model`). MCP does not standardize model identity:
46
+ # some clients expose it through vendor metadata, others let the agent
47
+ # self-report through the injected `llm_model` argument. Client metadata wins;
48
+ # `$mcp_llm_model_source` preserves provenance. Both are unverified.
49
+ #
50
+ # @api private
51
+ module ModelCapture
52
+ PARAM_NAME = 'llm_model'
53
+ CODEX_TURN_METADATA_KEY = 'x-codex-turn-metadata'
54
+
55
+ module_function
56
+
57
+ def normalize(model)
58
+ return nil unless model.is_a?(String)
59
+
60
+ trimmed = model.strip
61
+ trimmed.empty? || trimmed.casecmp('unknown').zero? ? nil : trimmed
62
+ end
63
+
64
+ # @param request [Hash] JSON-RPC-shaped request (params may carry `_meta`)
65
+ # @param self_reported [String, nil] the stripped `llm_model` argument, if the SDK owned it
66
+ # @return [Array(String, String), nil] `[model, source]`
67
+ def resolve(request, self_reported)
68
+ params = request[:params] || request['params'] || {}
69
+ meta = params[:_meta] || params['_meta']
70
+ codex = meta.is_a?(Hash) ? (meta[CODEX_TURN_METADATA_KEY] || meta[CODEX_TURN_METADATA_KEY.to_sym]) : nil
71
+ if codex.is_a?(Hash)
72
+ model = normalize(codex['model'] || codex[:model])
73
+ return [model, 'client_metadata'] if model
74
+ end
75
+
76
+ model = normalize(self_reported)
77
+ model ? [model, 'self_reported'] : nil
78
+ end
79
+ end
80
+
81
+ # Raw transport headers stamped per event (HTTP only, never cached).
82
+ #
83
+ # @api private
84
+ module TransportIdentity
85
+ CLIENT_USER_AGENT_HEADER = 'user-agent'
86
+ VENDOR_CLIENT_HEADER = 'x-anthropic-client'
87
+
88
+ module_function
89
+
90
+ def stamp(event, headers)
91
+ return event unless headers.is_a?(Hash)
92
+
93
+ user_agent = headers[CLIENT_USER_AGENT_HEADER]
94
+ vendor = headers[VENDOR_CLIENT_HEADER]
95
+ event['client_user_agent'] = user_agent if user_agent.is_a?(String) && !user_agent.empty?
96
+ event['vendor_client'] = vendor if vendor.is_a?(String) && !vendor.empty?
97
+ event
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Logging for the MCP integration.
6
+ #
7
+ # Debug-level chatter goes only to the `logger:` option (a no-op by default),
8
+ # because a stdio MCP server owns `$stdout` for the protocol and the core
9
+ # SDK's default logger writes there. Warnings additionally reach the app's
10
+ # logger when Rails is loaded (where {PostHog::Logging.logger} wraps
11
+ # `Rails.logger`) and stderr otherwise, so misconfiguration is never silent
12
+ # and never corrupts a stdio transport.
13
+ #
14
+ # @api private
15
+ module Log
16
+ module_function
17
+
18
+ def debug(options, message)
19
+ sink = options.respond_to?(:logger) ? options.logger : nil
20
+ sink&.call(message)
21
+ rescue StandardError
22
+ nil
23
+ end
24
+
25
+ def warn(options, message)
26
+ debug(options, message)
27
+ if defined?(::Rails)
28
+ PostHog::Logging.logger.warn(message)
29
+ else
30
+ Kernel.warn("[posthog-ruby] #{message}")
31
+ end
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Description override for the injected `context` argument.
6
+ #
7
+ # @!attribute description
8
+ # @return [String, nil]
9
+ ContextOptions = Struct.new(:description, keyword_init: true)
10
+
11
+ # Description override for the injected `llm_model` argument.
12
+ #
13
+ # @!attribute description
14
+ # @return [String, nil]
15
+ ModelOptions = Struct.new(:description, keyword_init: true)
16
+
17
+ # Resolved identity for a session. `distinct_id` becomes the event's
18
+ # distinct id, `properties` go to `$set`, `groups` (`{group_type => group_key}`)
19
+ # are stamped on every event as `$groups`.
20
+ UserIdentity = Struct.new(:distinct_id, :properties, :groups, keyword_init: true) do
21
+ # @api private
22
+ def self.coerce(value)
23
+ case value
24
+ when UserIdentity then value
25
+ when Hash
26
+ distinct_id = value[:distinct_id] || value['distinct_id'] || value[:distinctId] || value['distinctId']
27
+ return nil if distinct_id.nil? || distinct_id.to_s.empty?
28
+
29
+ new(
30
+ distinct_id: distinct_id.to_s,
31
+ properties: value[:properties] || value['properties'],
32
+ groups: value[:groups] || value['groups']
33
+ )
34
+ end
35
+ end
36
+ end
37
+
38
+ # Result of {PostHog::MCP::Client#prepare_tool_call}: the intent and the
39
+ # self-reported model pulled off the call, the arguments with the injected
40
+ # `context` and `llm_model` stripped, and whether the call targeted the
41
+ # `get_more_tools` virtual tool.
42
+ PreparedToolCall = Struct.new(:args, :intent, :intent_source, :llm_model, :llm_model_source,
43
+ :is_missing_capability, keyword_init: true)
44
+
45
+ # Configuration for {PostHog::MCP.instrument}.
46
+ #
47
+ # @note Experimental: option names may change in a future minor release.
48
+ class Options
49
+ # @return [#call, nil] STDIO-safe log sink receiving single String messages. Default: no-op.
50
+ attr_reader :logger
51
+ # @return [Boolean] Register the `get_more_tools` virtual tool. Default false.
52
+ attr_reader :report_missing
53
+ # @return [String] Name of the virtual tool. Default `get_more_tools`.
54
+ attr_reader :missing_capability_tool_name
55
+ # @return [Boolean] Inject `conversation_id` and anchor `$session_id` on it. Default false.
56
+ attr_reader :enable_conversation_id
57
+ # @return [Boolean] Emit a sibling `$exception` event for failed calls. Default true.
58
+ attr_reader :enable_exception_autocapture
59
+ # @return [Boolean, ContextOptions] Inject the required `context` argument. Default true.
60
+ attr_reader :context
61
+ # @return [Boolean, ModelOptions] Capture `$mcp_llm_model`. Default false.
62
+ attr_reader :capture_model
63
+ # @return [#call, UserIdentity, Hash, nil] `(request, extra) -> UserIdentity | Hash | nil`, or a static identity.
64
+ attr_reader :identify
65
+ # @return [#call, nil] `(request, extra) -> String | nil`, consulted when no `context` arg was passed.
66
+ attr_reader :intent_fallback
67
+ # @return [#call, nil] `(payload) -> payload | nil`; runs once per emitted payload, nil drops it.
68
+ attr_reader :before_send
69
+ # @return [#call, nil] `(request, extra) -> Hash | nil`, spread flat onto every auto-captured event.
70
+ attr_reader :event_properties
71
+
72
+ def initialize(logger: nil, report_missing: false, missing_capability_tool_name: nil,
73
+ enable_conversation_id: false, enable_exception_autocapture: true, context: true,
74
+ capture_model: false, identify: nil, intent_fallback: nil, before_send: nil,
75
+ event_properties: nil)
76
+ @logger = logger
77
+ @report_missing = report_missing == true
78
+ @missing_capability_tool_name = missing_capability_tool_name
79
+ @enable_conversation_id = enable_conversation_id == true
80
+ @enable_exception_autocapture = enable_exception_autocapture != false
81
+ @context = normalize_context(context)
82
+ @capture_model = normalize_model(capture_model)
83
+ @identify = identify
84
+ @intent_fallback = intent_fallback
85
+ @before_send = before_send
86
+ @event_properties = event_properties
87
+ end
88
+
89
+ # @return [Boolean]
90
+ def context_enabled?
91
+ @context != false
92
+ end
93
+
94
+ # @return [String, nil]
95
+ def context_description
96
+ @context.is_a?(ContextOptions) ? @context.description : nil
97
+ end
98
+
99
+ # @return [Boolean]
100
+ def capture_model_enabled?
101
+ @capture_model != false
102
+ end
103
+
104
+ # @return [String, nil]
105
+ def model_description
106
+ @capture_model.is_a?(ModelOptions) ? @capture_model.description : nil
107
+ end
108
+
109
+ private
110
+
111
+ def normalize_context(context)
112
+ case context
113
+ when false, nil then context.nil?
114
+ when Hash then ContextOptions.new(description: context[:description] || context['description'])
115
+ else context
116
+ end
117
+ end
118
+
119
+ def normalize_model(capture_model)
120
+ case capture_model
121
+ when Hash then ModelOptions.new(description: capture_model[:description] || capture_model['description'])
122
+ when true, ModelOptions then capture_model
123
+ else false
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Rack middleware for stateless / multi-pod MCP servers built on a custom
6
+ # Rack stack (not the `mcp` gem's Streamable HTTP transport, which
7
+ # {PostHog::MCP.instrument} wires automatically).
8
+ #
9
+ # It publishes the request's headers to {RequestScope}, so an instrumented
10
+ # `MCP::Server` dispatched anywhere below it sees the HTTP context the gem's
11
+ # own transport would have given it, and it carries the `Mcp-Session-Id`
12
+ # token minted at `initialize` back onto the response. Clients replay that
13
+ # token on every request, so any pod recovers `$session_id` and the client
14
+ # identity from the header alone.
15
+ #
16
+ # Neither the request nor the response body is read here. The token comes
17
+ # from whoever handled the request and already knows it succeeded: the
18
+ # instrumented server, or - for a hand-rolled dispatcher built on
19
+ # {PostHog::MCP::Client} - a call to the mint hook this middleware exposes as
20
+ # `env['posthog_mcp.mint']`.
21
+ #
22
+ # The decoded token (replayed or freshly minted) is exposed to the app as
23
+ # `env['posthog_mcp.session']` ({SessionTokenPayload}).
24
+ #
25
+ # @note Experimental. `PostHog::MCP` is not officially supported; see the
26
+ # docs at https://posthog.com/docs/mcp-analytics.
27
+ #
28
+ # @example An instrumented server behind a custom Rack stack
29
+ # use PostHog::MCP::RackMiddleware
30
+ #
31
+ # @example A hand-rolled dispatcher minting the session itself
32
+ # session = env['posthog_mcp.mint']&.call(
33
+ # client_name: info['name'], client_version: info['version'], protocol_version: params['protocolVersion']
34
+ # )
35
+ # analytics.capture_initialize(session_id: session&.session_id, ...)
36
+ class RackMiddleware
37
+ ENV_KEY = 'posthog_mcp.session'
38
+ MINT_ENV_KEY = 'posthog_mcp.mint'
39
+
40
+ def initialize(app)
41
+ @app = app
42
+ end
43
+
44
+ def call(env)
45
+ replayed = SessionToken.decode(SessionToken.read_header(MCP_SESSION_HEADER => env['HTTP_MCP_SESSION_ID']))
46
+ env[ENV_KEY] = replayed if replayed
47
+
48
+ RequestScope.with(headers: RequestScope.headers_from_env(env), transport: :http) do |scope|
49
+ env[MINT_ENV_KEY] = mint_hook(scope, env) unless replayed
50
+ status, headers, body = @app.call(env)
51
+ settle_token(scope[:mint], status, headers, env, replayed)
52
+ [status, headers, body]
53
+ ensure
54
+ env.delete(MINT_ENV_KEY)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # A token is minted only once the handshake produced an `InitializeResult`,
61
+ # so a rejected `initialize` - including a JSON-RPC error riding on a 200 -
62
+ # mints nothing. The status check covers a hand-rolled dispatcher that
63
+ # minted and then failed the request: the client never gets a session it
64
+ # cannot use, and `env` stops advertising one.
65
+ def settle_token(token, status, headers, env, replayed)
66
+ return if token.nil?
67
+
68
+ if success?(status) && attachable?(headers)
69
+ headers[MCP_SESSION_HEADER] = token
70
+ env[ENV_KEY] = SessionToken.decode(token)
71
+ elsif !replayed
72
+ env.delete(ENV_KEY)
73
+ end
74
+ end
75
+
76
+ def success?(status)
77
+ status.to_i.between?(200, 299)
78
+ end
79
+
80
+ def attachable?(headers)
81
+ headers.respond_to?(:key?) && headers.keys.none? { |key| key.to_s.casecmp?(MCP_SESSION_HEADER) }
82
+ end
83
+
84
+ # `env['posthog_mcp.mint']`, for a dispatcher that handles `initialize`
85
+ # itself: call it once the handshake is accepted to get the session this
86
+ # request belongs to. Only present when the client replayed no token, and
87
+ # nil for a modern-era client (protocol revision 2026-07-28 or later), which
88
+ # must not be answered with an `Mcp-Session-Id`.
89
+ #
90
+ # @return [Proc] `(client_name:, client_version:, protocol_version:) -> SessionTokenPayload | nil`
91
+ def mint_hook(scope, env)
92
+ lambda do |client_name: nil, client_version: nil, protocol_version: nil|
93
+ next env[ENV_KEY] if scope[:mint]
94
+ next nil unless Instrumentation.legacy_era?(protocol_version)
95
+
96
+ payload = SessionTokenPayload.new(
97
+ session_id: Session.new_session_id,
98
+ client_name: string_or_nil(client_name),
99
+ client_version: string_or_nil(client_version),
100
+ protocol_version: string_or_nil(protocol_version)
101
+ )
102
+ scope[:mint] = SessionToken.encode(payload)
103
+ env[ENV_KEY] = payload
104
+ end
105
+ end
106
+
107
+ def string_or_nil(value)
108
+ value.is_a?(String) && !value.empty? ? value : nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Request-scoped hand-off from the Streamable HTTP transport to the server
6
+ # extension. The `mcp` gem re-parses the JSON body between
7
+ # `StreamableHTTPTransport#handle_request` and `Server#handle_json`, so the
8
+ # HTTP headers cannot travel with the request object; this is the one place
9
+ # the integration relies on ambient state.
10
+ #
11
+ # Uses fiber storage (`Fiber[]`, Ruby 3.2+), which is per fiber, isolated per
12
+ # Ractor, and inherited by fibers/threads a tool spawns. Falls back to
13
+ # fiber-local `Thread.current[]` on Ruby 3.0/3.1.
14
+ #
15
+ # @api private
16
+ module RequestScope
17
+ KEY = :posthog_mcp_request_scope
18
+ FIBER_STORAGE = Fiber.respond_to?(:[]) && Fiber.respond_to?(:[]=)
19
+
20
+ # Rack `env` keys for the headers the integration reads, by header name.
21
+ HEADER_ENV_KEYS = {
22
+ 'user-agent' => 'HTTP_USER_AGENT',
23
+ 'x-anthropic-client' => 'HTTP_X_ANTHROPIC_CLIENT',
24
+ 'mcp-session-id' => 'HTTP_MCP_SESSION_ID',
25
+ 'mcp-protocol-version' => 'HTTP_MCP_PROTOCOL_VERSION'
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # @return [Hash, nil] `{headers:, transport:, mint:, session_id:}` for the in-flight HTTP request,
31
+ # plus `actor:` once {Instrumentation} has resolved identity for it.
32
+ # `session_id` is the `$session_id` {Instrumentation} settled on before running the
33
+ # tool body, and `actor` the identity it resolved, so a custom event captured inside
34
+ # the tool is attributed to this request - to the right session and the right person -
35
+ # even while another request on the same server is in flight. `actor` is absent until
36
+ # the request resolves identity, which is what tells {Analytics} it has none to use.
37
+ def current
38
+ FIBER_STORAGE ? Fiber[KEY] : Thread.current[KEY]
39
+ end
40
+
41
+ def current=(value)
42
+ if FIBER_STORAGE
43
+ Fiber[KEY] = value
44
+ else
45
+ Thread.current[KEY] = value
46
+ end
47
+ end
48
+
49
+ # @param headers [Hash{String => String}] lowercase header names
50
+ # @param transport [Symbol] `:http` for the Streamable HTTP transport, `:other`
51
+ # for a transport that publishes no headers (stdio, a custom dispatcher)
52
+ # @param env [Hash] a Rack `env`
53
+ # @return [Hash{String => String}] the headers {Instrumentation} reads, lowercase
54
+ def headers_from_env(env)
55
+ return {} unless env.is_a?(Hash)
56
+
57
+ HEADER_ENV_KEYS.each_with_object({}) do |(name, env_key), acc|
58
+ value = env[env_key]
59
+ acc[name] = value if value.is_a?(String) && !value.empty?
60
+ end
61
+ end
62
+
63
+ def with(headers:, transport: :http)
64
+ previous = current
65
+ self.current = { headers: headers, transport: transport, mint: nil, session_id: nil }
66
+ yield current
67
+ ensure
68
+ self.current = previous
69
+ end
70
+ end
71
+ end
72
+ end