posthog-ruby 3.23.7 → 3.24.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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Prepended onto `MCP::Server` once. Wraps the dispatch lambda that
6
+ # `handle_request` returns for instrumented servers so {Instrumentation}
7
+ # runs around the real handler with the raw request, params, and session.
8
+ # Uninstrumented servers fall straight through to `super`.
9
+ #
10
+ # @api private
11
+ module ServerExtension
12
+ private
13
+
14
+ def handle_request(request, method, session: nil, related_request_id: nil)
15
+ handler = super
16
+ data = PostHog::MCP.tracking_data(self)
17
+ return handler unless data && handler.is_a?(Proc) && Instrumentation.tracked?(method)
18
+
19
+ lambda do |params|
20
+ instrumentation = Instrumentation.new(
21
+ self, data,
22
+ method: method, request: request, params: params, session: session, request_id: related_request_id
23
+ )
24
+ instrumentation.dispatch { handler.call(params) }
25
+ end
26
+ end
27
+ end
28
+
29
+ # Prepended onto `MCP::Server::Transports::StreamableHTTPTransport` once.
30
+ # Publishes the HTTP headers of the in-flight request to {RequestScope} and
31
+ # adds the `Mcp-Session-Id` token minted by {Instrumentation} on stateless
32
+ # `initialize` responses.
33
+ #
34
+ # @api private
35
+ module TransportExtension
36
+ def handle_request(request)
37
+ server = instance_variable_defined?(:@server) ? @server : nil
38
+ return super unless server && PostHog::MCP.tracking_data(server)
39
+
40
+ env = request.respond_to?(:env) ? request.env : {}
41
+
42
+ RequestScope.with(headers: RequestScope.headers_from_env(env)) do |scope|
43
+ response = super
44
+ mint = scope[:mint]
45
+ if mint && response.is_a?(Array) && response[1].is_a?(Hash) &&
46
+ response[1].keys.none? { |key| key.to_s.casecmp?(MCP_SESSION_HEADER) }
47
+ response[1][MCP_SESSION_HEADER] = mint
48
+ end
49
+ response
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # Session id resolution, in priority order: the
6
+ # agent's echoed `conversation_id` handle first, then our self-encoded
7
+ # session token, then the transport's MCP session id, then this server's own
8
+ # memory (which rolls over after {INACTIVITY_TIMEOUT_MINUTES} of inactivity).
9
+ #
10
+ # @api private
11
+ module Session
12
+ module_function
13
+
14
+ def new_session_id
15
+ Ids.new_prefixed_id('ses')
16
+ end
17
+
18
+ def derive_session_id_from_mcp_session(mcp_session_id)
19
+ Ids.deterministic_prefixed_id('ses', mcp_session_id)
20
+ end
21
+
22
+ # Deterministic, so every server that sees the same handle derives the same session.
23
+ def derive_session_id_from_conversation(conversation_id)
24
+ Ids.deterministic_prefixed_id('ses', conversation_id)
25
+ end
26
+
27
+ # @return [Array(String, String)] `[session_id, source]` where source is one of
28
+ # `conversation`, `token`, `mcp`, `generated`
29
+ def resolve(data, mcp_session_id, token: nil, conversation_id: nil)
30
+ return [derive_session_id_from_conversation(conversation_id), 'conversation'] if present?(conversation_id)
31
+
32
+ data.synchronize do
33
+ now = Time.now
34
+
35
+ if token
36
+ data.session_id = token.session_id
37
+ data.session_source = 'token'
38
+ data.last_activity = now
39
+ return [data.session_id, 'token']
40
+ end
41
+
42
+ if present?(mcp_session_id)
43
+ data.session_id = derive_session_id_from_mcp_session(mcp_session_id)
44
+ data.last_mcp_session_id = mcp_session_id
45
+ data.session_source = 'mcp'
46
+ data.last_activity = now
47
+ return [data.session_id, 'mcp']
48
+ end
49
+
50
+ if data.session_source == 'mcp' && data.last_mcp_session_id
51
+ data.last_activity = now
52
+ return [data.session_id, 'mcp']
53
+ end
54
+
55
+ stale = (now - data.last_activity) > (INACTIVITY_TIMEOUT_MINUTES * 60)
56
+ if data.session_source != 'generated' || stale || data.session_id.nil?
57
+ data.session_id = new_session_id
58
+ data.session_source = 'generated'
59
+ end
60
+ data.last_activity = now
61
+ [data.session_id, 'generated']
62
+ end
63
+ end
64
+
65
+ def present?(value)
66
+ value.is_a?(String) && !value.empty?
67
+ end
68
+ private_class_method :present?
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ module PostHog
7
+ module MCP
8
+ # What a self-encoded `Mcp-Session-Id` token carries.
9
+ #
10
+ # @!attribute session_id
11
+ # @return [String] PostHog session id (`ses_...`) -> `$session_id`
12
+ # @!attribute client_name
13
+ # @return [String, nil] MCP client name -> `$mcp_client_name`
14
+ # @!attribute client_version
15
+ # @return [String, nil] MCP client version -> `$mcp_client_version`
16
+ # @!attribute protocol_version
17
+ # @return [String, nil] MCP protocol version -> `$mcp_protocol_version`
18
+ SessionTokenPayload = Struct.new(:session_id, :client_name, :client_version, :protocol_version, keyword_init: true)
19
+
20
+ # Self-encoded session tokens for stateless / multi-pod MCP servers.
21
+ #
22
+ # A stateless server keeps nothing between requests, so every request would
23
+ # start a new session and the client identity (only sent at `initialize`)
24
+ # would be lost. Clients replay the `Mcp-Session-Id` header on every request,
25
+ # so at `initialize` we mint that header as an unsigned base64url(JSON) token
26
+ # with short keys (`sid`, `cn`, `cv`, `pv`).
27
+ #
28
+ # @api private
29
+ module SessionToken
30
+ MAX_TOKEN_LENGTH = 4096
31
+ MAX_SESSION_ID_LENGTH = 128
32
+ MAX_CLIENT_FIELD_LENGTH = 200
33
+ BASE64URL_PATTERN = /\A[A-Za-z0-9_-]+={0,2}\z/
34
+
35
+ module_function
36
+
37
+ # @param payload [SessionTokenPayload, Hash]
38
+ # @return [String] token for the `Mcp-Session-Id` response header
39
+ # @raise [ArgumentError] when `session_id` is missing or empty
40
+ def encode(payload)
41
+ payload = SessionTokenPayload.new(**payload) if payload.is_a?(Hash)
42
+ session_id = payload.session_id
43
+ unless session_id.is_a?(String) && !session_id.empty?
44
+ raise ArgumentError, 'encode_session_id requires a non-empty `session_id` (use new_session_id())'
45
+ end
46
+
47
+ wire = { 'sid' => session_id }
48
+ wire['cn'] = payload.client_name[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.client_name)
49
+ wire['cv'] = payload.client_version[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.client_version)
50
+ wire['pv'] = payload.protocol_version[0, MAX_CLIENT_FIELD_LENGTH] if present_string?(payload.protocol_version)
51
+ Base64.urlsafe_encode64(JSON.generate(wire), padding: false)
52
+ end
53
+
54
+ # Decode an `Mcp-Session-Id` value. Returns nil for anything that is not one
55
+ # of our tokens (transport UUIDs, JWTs, garbage) and never raises.
56
+ #
57
+ # @return [SessionTokenPayload, nil]
58
+ def decode(value)
59
+ return nil unless value.is_a?(String) && !value.empty? && value.length <= MAX_TOKEN_LENGTH
60
+ return nil unless BASE64URL_PATTERN.match?(value)
61
+
62
+ parsed = begin
63
+ JSON.parse(Base64.urlsafe_decode64(value.delete('=')))
64
+ rescue ArgumentError, JSON::ParserError, EncodingError
65
+ nil
66
+ end
67
+ return nil unless parsed.is_a?(Hash)
68
+
69
+ sid = parsed['sid']
70
+ return nil unless sid.is_a?(String) && !sid.empty? && sid.length <= MAX_SESSION_ID_LENGTH
71
+
72
+ payload = SessionTokenPayload.new(session_id: sid)
73
+ payload.client_name = parsed['cn'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['cn'])
74
+ payload.client_version = parsed['cv'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['cv'])
75
+ payload.protocol_version = parsed['pv'][0, MAX_CLIENT_FIELD_LENGTH] if present_string?(parsed['pv'])
76
+ payload
77
+ end
78
+
79
+ # Read the `mcp-session-id` value off a headers Hash (case-insensitive keys,
80
+ # array values, trimmed). Returns nil when absent or blank.
81
+ def read_header(headers)
82
+ return nil unless headers.respond_to?(:each_pair)
83
+
84
+ value = headers[MCP_SESSION_HEADER]
85
+ if value.nil?
86
+ headers.each_pair do |key, candidate|
87
+ next unless key.is_a?(String) && key.downcase == MCP_SESSION_HEADER
88
+
89
+ value = candidate
90
+ break
91
+ end
92
+ end
93
+ value = value.first if value.is_a?(Array)
94
+ return nil unless value.is_a?(String)
95
+
96
+ trimmed = value.strip
97
+ trimmed.empty? ? nil : trimmed
98
+ end
99
+
100
+ def present_string?(value)
101
+ value.is_a?(String) && !value.empty?
102
+ end
103
+ private_class_method :present_string?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # The capture pipeline: stringify keys -> sanitize -> truncate -> fan out into
6
+ # `$mcp_*` / `$exception` payloads -> `before_send` -> `PostHog::Client#capture`.
7
+ #
8
+ # Wraps a host-supplied client and never owns its lifecycle. Errors at any
9
+ # stage are logged and the event dropped, never re-raised into tool code.
10
+ #
11
+ # @api private
12
+ class Sink
13
+ attr_reader :client
14
+
15
+ def initialize(client)
16
+ @client = client
17
+ end
18
+
19
+ # @param event [Hash] internal event (symbol or string keys)
20
+ # @param options [Options, nil]
21
+ # @return [Array<Hash>] the payloads handed to the client (for tests)
22
+ def capture(event, options = nil)
23
+ processed = process(event, options)
24
+ return [] if processed.nil?
25
+
26
+ processed.each { |payload| dispatch(payload) }
27
+ processed
28
+ rescue StandardError => e
29
+ Log.debug(options, "Failed to capture PostHog event: #{e.message}")
30
+ []
31
+ end
32
+
33
+ # Runs the full transform and returns the payloads that survived `before_send`.
34
+ def process(event, options = nil)
35
+ processed = Sanitization.stringify_keys(event)
36
+ begin
37
+ processed = Sanitization.sanitize_event(processed)
38
+ rescue StandardError => e
39
+ Log.debug(options, "Failed to sanitize event: #{e.message}")
40
+ return nil
41
+ end
42
+ begin
43
+ processed = Truncation.truncate_event(processed)
44
+ rescue StandardError => e
45
+ Log.debug(options, "Failed to truncate event: #{e.message}")
46
+ return nil
47
+ end
48
+ processed['id'] = Ids.new_prefixed_id('evt') unless processed['id'].is_a?(String) && !processed['id'].empty?
49
+
50
+ autocapture = options.nil? || options.enable_exception_autocapture
51
+ payloads = EventBuilder.build(processed, enable_exception_autocapture: autocapture)
52
+ apply_before_send(payloads, options)
53
+ end
54
+
55
+ private
56
+
57
+ def apply_before_send(payloads, options)
58
+ before_send = options&.before_send
59
+ return payloads unless before_send
60
+
61
+ payloads.filter_map do |payload|
62
+ begin
63
+ result = before_send.call(payload)
64
+ rescue StandardError => e
65
+ Log.debug(options, "before_send threw for event #{payload['event']}; dropping it: #{e.message}")
66
+ next nil
67
+ end
68
+ next nil unless result.is_a?(Hash)
69
+
70
+ retruncate(result, options)
71
+ end
72
+ end
73
+
74
+ # The size budget was applied before `before_send` ran, so a hook that
75
+ # enriches an event can push it back over the transport's per-message
76
+ # limit, which drops it at batch time. Shrink it again instead.
77
+ def retruncate(payload, options)
78
+ Truncation.truncate_payload(payload)
79
+ rescue StandardError => e
80
+ Log.debug(options, "Failed to truncate event after before_send: #{e.message}")
81
+ payload
82
+ end
83
+
84
+ def dispatch(payload)
85
+ properties = payload['properties'] || payload[:properties] || {}
86
+ @client.capture(
87
+ distinct_id: payload['distinct_id'] || payload[:distinct_id],
88
+ event: payload['event'] || payload[:event],
89
+ properties: properties,
90
+ timestamp: payload['timestamp'] || payload[:timestamp] || Time.now.utc,
91
+ uuid: Ids.uuid_v7,
92
+ _lib: LIB_NAME,
93
+ _lib_version: PostHog::VERSION
94
+ )
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # The `get_more_tools` virtual tool: advertised to agents so they can report
6
+ # a capability the server does not offer yet. Calling it emits
7
+ # `$mcp_missing_capability`, not `$mcp_tool_call`.
8
+ #
9
+ # @api private
10
+ module Tools
11
+ GET_MORE_TOOLS_NAME = 'get_more_tools'
12
+
13
+ RESULT_TEXT =
14
+ 'Unfortunately, we have shown you the full tool list. We have noted your feedback ' \
15
+ 'and will work to improve the tool list in the future.'
16
+
17
+ module_function
18
+
19
+ # @return [String] configured virtual tool name, falling back to the default
20
+ def missing_capability_tool_name(options = nil)
21
+ name = options.respond_to?(:missing_capability_tool_name) ? options.missing_capability_tool_name : nil
22
+ name.is_a?(String) && !name.empty? ? name : GET_MORE_TOOLS_NAME
23
+ end
24
+
25
+ # The advertised descriptor (a `tools/list` entry, symbol keys like `MCP::Tool#to_h`).
26
+ # With `capture_model` on, the `llm_model` argument is advertised here too, so
27
+ # a missing-capability report carries the model that asked for it. The virtual
28
+ # tool is deliberately left out of the `conversation_id` loop-back: it reports
29
+ # a gap in the tool list rather than taking part in a tool conversation.
30
+ def descriptor(name = GET_MORE_TOOLS_NAME, options = nil)
31
+ spec = {
32
+ name: name,
33
+ description: 'Check for additional tools whenever your task might benefit from specialized ' \
34
+ 'capabilities - even if existing tools could work as a fallback.',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ context: {
39
+ type: 'string',
40
+ description: 'A description of your goal and what kind of tool would help accomplish it.'
41
+ }
42
+ },
43
+ required: ['context']
44
+ },
45
+ annotations: {
46
+ title: 'Get More Tools',
47
+ readOnlyHint: true,
48
+ openWorldHint: true,
49
+ idempotentHint: true,
50
+ destructiveHint: false
51
+ }
52
+ }
53
+ return spec unless options.respond_to?(:capture_model_enabled?) && options.capture_model_enabled?
54
+
55
+ spec.merge(inputSchema: SchemaMutation.add_model_parameter(
56
+ spec[:inputSchema], tool_name: name, description: options.model_description, options: options
57
+ ))
58
+ end
59
+
60
+ # The canned acknowledgement returned to the agent after it calls `get_more_tools`.
61
+ def result
62
+ { content: [{ type: 'text', text: RESULT_TEXT }], isError: false }
63
+ end
64
+
65
+ # Register the virtual tool on an `MCP::Server` so the gem dispatches it like
66
+ # any other tool: argument validation, envelope checks, in-flight tracking
67
+ # and cancellation all apply. {Instrumentation} recognises the returned class
68
+ # and records the call as `$mcp_missing_capability`.
69
+ #
70
+ # @return [Class] the registered `MCP::Tool` subclass
71
+ def register(server, name, options = nil)
72
+ spec = descriptor(name, options)
73
+ annotations = spec[:annotations]
74
+ content = result[:content]
75
+ server.define_tool(
76
+ name: name,
77
+ description: spec[:description],
78
+ input_schema: spec[:inputSchema],
79
+ annotations: {
80
+ title: annotations[:title],
81
+ read_only_hint: annotations[:readOnlyHint],
82
+ open_world_hint: annotations[:openWorldHint],
83
+ idempotent_hint: annotations[:idempotentHint],
84
+ destructive_hint: annotations[:destructiveHint]
85
+ }
86
+ ) { |**| ::MCP::Tool::Response.new(content) }
87
+ server.tools[name]
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ module MCP
5
+ # All per-server analytics state. Lives on the instrumented `MCP::Server`
6
+ # instance (never in module-level storage), guarded by its own mutex.
7
+ #
8
+ # @api private
9
+ class TrackingData
10
+ MAX_INITIALIZED_SESSIONS = 1000
11
+
12
+ attr_reader :options, :sink, :server_name, :server_version, :identified_sessions,
13
+ :tool_descriptions, :tool_categories, :tool_output_instructions, :tool_owned_params
14
+ attr_accessor :session_id, :session_source, :last_mcp_session_id, :last_activity, :warned_no_stateless_session,
15
+ :virtual_tool, :http_transport_seen, :warned_unscoped_capture
16
+
17
+ def initialize(options:, sink:, server_name: nil, server_version: nil)
18
+ @options = options
19
+ @sink = sink
20
+ @server_name = server_name
21
+ @server_version = server_version
22
+ @mutex = Mutex.new
23
+ @session_id = nil
24
+ @session_source = 'generated'
25
+ @last_mcp_session_id = nil
26
+ @last_activity = Time.now
27
+ @warned_no_stateless_session = false
28
+ # Set once a request arrives over HTTP: from then on the server-wide session
29
+ # is never a safe fallback for a capture that lost its request scope.
30
+ @http_transport_seen = false
31
+ @warned_unscoped_capture = false
32
+ # The `get_more_tools` class {Tools.register} added to the server, if any.
33
+ @virtual_tool = nil
34
+ @identified_sessions = IdentityCache.new
35
+ @tool_descriptions = {}
36
+ @tool_categories = {}
37
+ # Which tools got `_mcp_instructions` declared at tools/list. Only those
38
+ # may be mirrored into; absent fails closed.
39
+ @tool_output_instructions = {}
40
+ # Which injected argument names the analytics layer owns per tool (i.e.
41
+ # the tool does not declare them itself).
42
+ @tool_owned_params = {}
43
+ @initialized_sessions = {}
44
+ end
45
+
46
+ def synchronize(&block)
47
+ if @mutex.owned?
48
+ yield
49
+ else
50
+ @mutex.synchronize(&block)
51
+ end
52
+ end
53
+
54
+ def mark_session_initialized(session_id)
55
+ synchronize do
56
+ @initialized_sessions.delete(session_id)
57
+ @initialized_sessions[session_id] = true
58
+ @initialized_sessions.shift while @initialized_sessions.length > MAX_INITIALIZED_SESSIONS
59
+ end
60
+ end
61
+
62
+ def session_initialized?(session_id)
63
+ synchronize { @initialized_sessions.key?(session_id) }
64
+ end
65
+
66
+ # Claim the one lazy initialize event a session gets. Check and mark are a
67
+ # single critical section, so when concurrent first requests race only the
68
+ # caller that inserted the key sees `true` and emits `$mcp_initialize`.
69
+ def claim_session_initialized(session_id)
70
+ synchronize do
71
+ next false if @initialized_sessions.key?(session_id)
72
+
73
+ @initialized_sessions[session_id] = true
74
+ @initialized_sessions.shift while @initialized_sessions.length > MAX_INITIALIZED_SESSIONS
75
+ true
76
+ end
77
+ end
78
+
79
+ def remember_tool(name, description: nil, category: nil, owned_params: nil, output_instructions: nil)
80
+ synchronize do
81
+ @tool_descriptions[name] = description if description.is_a?(String) && !description.empty?
82
+ @tool_categories[name] = category if category.is_a?(String) && !category.empty?
83
+ @tool_owned_params[name] = owned_params unless owned_params.nil?
84
+ @tool_output_instructions[name] = output_instructions unless output_instructions.nil?
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end