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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +12 -6
- data/lib/posthog/feature_flags.rb +155 -60
- data/lib/posthog/flag_definition_cache.rb +9 -5
- data/lib/posthog/mcp/README.md +53 -0
- data/lib/posthog/mcp/analytics.rb +116 -0
- data/lib/posthog/mcp/client.rb +246 -0
- data/lib/posthog/mcp/constants.rb +111 -0
- data/lib/posthog/mcp/conversation_id.rb +89 -0
- data/lib/posthog/mcp/event_builder.rb +168 -0
- data/lib/posthog/mcp/exceptions.rb +112 -0
- data/lib/posthog/mcp/identity.rb +194 -0
- data/lib/posthog/mcp/ids.rb +56 -0
- data/lib/posthog/mcp/instrumentation.rb +729 -0
- data/lib/posthog/mcp/intent.rb +101 -0
- data/lib/posthog/mcp/log.rb +37 -0
- data/lib/posthog/mcp/options.rb +128 -0
- data/lib/posthog/mcp/rack_middleware.rb +112 -0
- data/lib/posthog/mcp/request_scope.rb +72 -0
- data/lib/posthog/mcp/sanitization.rb +504 -0
- data/lib/posthog/mcp/schema_mutation.rb +179 -0
- data/lib/posthog/mcp/server_extension.rb +54 -0
- data/lib/posthog/mcp/session.rb +71 -0
- data/lib/posthog/mcp/session_token.rb +106 -0
- data/lib/posthog/mcp/sink.rb +98 -0
- data/lib/posthog/mcp/tools.rb +91 -0
- data/lib/posthog/mcp/tracking_data.rb +89 -0
- data/lib/posthog/mcp/truncation.rb +326 -0
- data/lib/posthog/mcp.rb +216 -0
- data/lib/posthog/version.rb +1 -1
- metadata +26 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PostHog
|
|
4
|
+
module MCP
|
|
5
|
+
# Handle returned by {PostHog::MCP.instrument}. Emits custom events onto the
|
|
6
|
+
# same pipeline as the auto-captured `$mcp_*` events.
|
|
7
|
+
#
|
|
8
|
+
# @note Experimental.
|
|
9
|
+
class Analytics
|
|
10
|
+
# @api private
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Capture a custom event scoped to the current MCP session. The event
|
|
16
|
+
# name is sent verbatim (a customer event, not `$`-prefixed).
|
|
17
|
+
#
|
|
18
|
+
# Inside a tool body the session and the identity are the ones pinned to
|
|
19
|
+
# the in-flight request by {Instrumentation}, so a concurrent request on
|
|
20
|
+
# the same session cannot reattribute this event. Over stdio, where a
|
|
21
|
+
# server only ever talks to one client, the session is the server's
|
|
22
|
+
# current one. On an HTTP server a call that has lost the request scope
|
|
23
|
+
# gets a standalone session rather than the server's, which may belong to
|
|
24
|
+
# another caller's request.
|
|
25
|
+
#
|
|
26
|
+
# @param event [String] event name
|
|
27
|
+
# @param properties [Hash] event properties
|
|
28
|
+
# @return [void]
|
|
29
|
+
# @raise [ArgumentError] when the event name is blank
|
|
30
|
+
def capture(event, properties = {})
|
|
31
|
+
unless event.is_a?(String) && !event.strip.empty?
|
|
32
|
+
raise ArgumentError, 'capture() requires an event name, e.g. analytics.capture("feedback_submitted")'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
data = PostHog::MCP.tracking_data(@server)
|
|
36
|
+
return if data.nil?
|
|
37
|
+
|
|
38
|
+
scope = RequestScope.current
|
|
39
|
+
scope = nil unless scope.is_a?(Hash)
|
|
40
|
+
Instrumentation.capture_event(data, {
|
|
41
|
+
'session_id' => current_session_id(data, scope),
|
|
42
|
+
'event_type' => EventType::CUSTOM,
|
|
43
|
+
'event_name' => event,
|
|
44
|
+
'timestamp' => Time.now.utc,
|
|
45
|
+
'properties' => properties
|
|
46
|
+
}, actor: scoped_actor(scope))
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Flush the underlying PostHog client.
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
def flush
|
|
54
|
+
data = PostHog::MCP.tracking_data(@server)
|
|
55
|
+
client = data&.sink&.client
|
|
56
|
+
client.flush if client.respond_to?(:flush)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The session pinned to the in-flight request, when there is one. On a server
|
|
63
|
+
# that has served an HTTP request the server-wide session is not a safe
|
|
64
|
+
# fallback: it belongs to whichever request settled it last, which under
|
|
65
|
+
# concurrency is somebody else's. That happens when a tool hands its work to
|
|
66
|
+
# a thread or fiber it spawned on Ruby 3.0/3.1, where {RequestScope} is
|
|
67
|
+
# fiber-local and is not inherited. Fail closed with a standalone session
|
|
68
|
+
# rather than filing the event under another caller's identity; over stdio a
|
|
69
|
+
# server only ever talks to one client, so the fallback stays.
|
|
70
|
+
def current_session_id(data, scope)
|
|
71
|
+
scoped = scope ? scope[:session_id] : nil
|
|
72
|
+
return scoped if scoped
|
|
73
|
+
return data.session_id unless data.http_transport_seen
|
|
74
|
+
|
|
75
|
+
warn_unscoped_capture(data)
|
|
76
|
+
Session.new_session_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The identity the in-flight request resolved, which {Instrumentation} pinned
|
|
80
|
+
# on the scope before running the tool body. Absent outside a request it
|
|
81
|
+
# started, and only then is the session-keyed cache consulted instead.
|
|
82
|
+
def scoped_actor(scope)
|
|
83
|
+
scope&.key?(:actor) ? scope[:actor] : Instrumentation::UNRESOLVED_ACTOR
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def warn_unscoped_capture(data)
|
|
87
|
+
return if data.warned_unscoped_capture
|
|
88
|
+
|
|
89
|
+
data.warned_unscoped_capture = true
|
|
90
|
+
Log.warn(
|
|
91
|
+
data.options,
|
|
92
|
+
'Warning: analytics.capture() ran without the scope of the request that started it, so its event got ' \
|
|
93
|
+
'a standalone $session_id instead of the caller\'s. On Ruby 3.2+ the scope follows threads and fibers ' \
|
|
94
|
+
'a tool spawns; before that it does not, so capture custom events from the tool body itself.'
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returned when instrumentation could not be set up; every call is a no-op.
|
|
100
|
+
#
|
|
101
|
+
# @api private
|
|
102
|
+
class NoopAnalytics < Analytics
|
|
103
|
+
def initialize
|
|
104
|
+
super(nil)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def capture(_event = nil, _properties = {})
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def flush
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PostHog
|
|
4
|
+
module MCP
|
|
5
|
+
# A {PostHog::Client} with first-class MCP analytics for custom dispatchers
|
|
6
|
+
# (your own HTTP layer, no `MCP::Server` to wrap). The host resolves
|
|
7
|
+
# identity and context per request and calls the capture methods directly;
|
|
8
|
+
# events flow through the same sanitize -> truncate -> `$exception` fan-out
|
|
9
|
+
# pipeline as {PostHog::MCP.instrument}. Does not need the `mcp` gem.
|
|
10
|
+
#
|
|
11
|
+
# @note Experimental.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# posthog = PostHog::MCP::Client.new(api_key: 'phc_...', host: 'https://us.i.posthog.com')
|
|
15
|
+
# posthog.capture_tool_call('search_docs', duration_ms: 42, distinct_id: 'user_123')
|
|
16
|
+
class Client < PostHog::Client
|
|
17
|
+
# @param opts [Hash] {PostHog::Client} options plus:
|
|
18
|
+
# @option opts [String] :missing_capability_tool_name name of the virtual tool (default `get_more_tools`)
|
|
19
|
+
# @option opts [Boolean] :mcp_exception_autocapture emit a sibling `$exception` for failed calls (default true)
|
|
20
|
+
def initialize(opts = {})
|
|
21
|
+
opts = opts.transform_keys(&:to_sym)
|
|
22
|
+
@missing_capability_tool_name = opts.delete(:missing_capability_tool_name) || Tools::GET_MORE_TOOLS_NAME
|
|
23
|
+
@mcp_exception_autocapture = opts.delete(:mcp_exception_autocapture) != false
|
|
24
|
+
super
|
|
25
|
+
@mcp_sink = Sink.new(self)
|
|
26
|
+
@mcp_options = Options.new(
|
|
27
|
+
enable_exception_autocapture: @mcp_exception_autocapture,
|
|
28
|
+
missing_capability_tool_name: @missing_capability_tool_name
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Capture a tool invocation. Emits `$mcp_tool_call` (+ `$exception` on error).
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def capture_tool_call(tool_name, intent: nil, intent_source: nil, parameters: nil, response: nil,
|
|
36
|
+
duration_ms: nil, is_error: false, error: nil, error_type: nil, category: nil,
|
|
37
|
+
tool_description: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
|
|
38
|
+
client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
|
|
39
|
+
properties: nil, timestamp: nil, llm_model: nil, llm_model_source: nil)
|
|
40
|
+
event = base_event(EventType::MCP_TOOLS_CALL, distinct_id, session_id, set_properties, groups, properties,
|
|
41
|
+
timestamp, client_user_agent, vendor_client)
|
|
42
|
+
event['resource_name'] = tool_name
|
|
43
|
+
event['tool_description'] = tool_description
|
|
44
|
+
event['tool_category'] = category
|
|
45
|
+
event['protocol_version'] = protocol_version
|
|
46
|
+
event['parameters'] = parameters
|
|
47
|
+
event['response'] = response
|
|
48
|
+
event['duration'] = duration_ms
|
|
49
|
+
event['is_error'] = is_error == true
|
|
50
|
+
event['error_type'] = error_type
|
|
51
|
+
apply_intent(event, intent, intent_source)
|
|
52
|
+
apply_model(event, llm_model, llm_model_source)
|
|
53
|
+
if is_error
|
|
54
|
+
event['error'] =
|
|
55
|
+
Exceptions.capture_exception(error.nil? ? "Tool #{tool_name} returned an error" : error)
|
|
56
|
+
end
|
|
57
|
+
emit(event)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Capture the connection handshake. Emits `$mcp_initialize`.
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
def capture_initialize(client_name: nil, client_version: nil, protocol_version: nil, parameters: nil,
|
|
64
|
+
response: nil, duration_ms: nil, distinct_id: nil, session_id: nil,
|
|
65
|
+
client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
|
|
66
|
+
properties: nil, timestamp: nil)
|
|
67
|
+
event = base_event(EventType::MCP_INITIALIZE, distinct_id, session_id, set_properties, groups, properties,
|
|
68
|
+
timestamp, client_user_agent, vendor_client)
|
|
69
|
+
event['client_name'] = client_name
|
|
70
|
+
event['client_version'] = client_version
|
|
71
|
+
event['protocol_version'] = protocol_version
|
|
72
|
+
event['parameters'] = parameters
|
|
73
|
+
event['response'] = response
|
|
74
|
+
event['duration'] = duration_ms
|
|
75
|
+
emit(event)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Capture a `tools/list` response. Emits `$mcp_tools_list` with `$mcp_listed_tool_names`.
|
|
79
|
+
#
|
|
80
|
+
# @return [void]
|
|
81
|
+
def capture_tools_list(tool_names: nil, parameters: nil, response: nil, duration_ms: nil, is_error: false,
|
|
82
|
+
error: nil, error_type: nil, protocol_version: nil, distinct_id: nil, session_id: nil,
|
|
83
|
+
client_user_agent: nil, vendor_client: nil, set_properties: nil, groups: nil,
|
|
84
|
+
properties: nil, timestamp: nil)
|
|
85
|
+
event = base_event(EventType::MCP_TOOLS_LIST, distinct_id, session_id, set_properties, groups, properties,
|
|
86
|
+
timestamp, client_user_agent, vendor_client)
|
|
87
|
+
event['listed_tool_names'] = tool_names
|
|
88
|
+
event['protocol_version'] = protocol_version
|
|
89
|
+
event['parameters'] = parameters
|
|
90
|
+
event['response'] = response
|
|
91
|
+
event['duration'] = duration_ms
|
|
92
|
+
event['is_error'] = is_error == true
|
|
93
|
+
event['error_type'] = error_type
|
|
94
|
+
event['error'] = Exceptions.capture_exception(error.nil? ? 'tools/list failed' : error) if is_error
|
|
95
|
+
emit(event)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Capture a `get_more_tools` call as a missing-capability report. Emits
|
|
99
|
+
# `$mcp_missing_capability` with the agent's description as `$mcp_intent`.
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
102
|
+
def capture_missing_capability(context: nil, parameters: nil, protocol_version: nil, distinct_id: nil,
|
|
103
|
+
session_id: nil, client_user_agent: nil, vendor_client: nil,
|
|
104
|
+
set_properties: nil, groups: nil, properties: nil, timestamp: nil,
|
|
105
|
+
llm_model: nil, llm_model_source: nil)
|
|
106
|
+
event = base_event(EventType::MCP_MISSING_CAPABILITY, distinct_id, session_id, set_properties, groups,
|
|
107
|
+
properties, timestamp, client_user_agent, vendor_client)
|
|
108
|
+
event['resource_name'] = @missing_capability_tool_name
|
|
109
|
+
event['protocol_version'] = protocol_version
|
|
110
|
+
event['parameters'] = parameters
|
|
111
|
+
apply_intent(event, context, 'context_parameter')
|
|
112
|
+
apply_model(event, llm_model, llm_model_source)
|
|
113
|
+
emit(event)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Inject the `context` argument (and, with `capture_model`, `llm_model`) into
|
|
117
|
+
# every tool descriptor (Hash with `inputSchema`) so agents state their intent,
|
|
118
|
+
# and optionally append the `get_more_tools` virtual tool. Returns a new Array
|
|
119
|
+
# of new Hashes. A tool whose schema is composed (oneOf/allOf/anyOf) or a
|
|
120
|
+
# `$ref` is passed through untouched.
|
|
121
|
+
#
|
|
122
|
+
# @param tools [Array<Hash>] `tools/list` entries
|
|
123
|
+
# @return [Array<Hash>]
|
|
124
|
+
def prepare_tool_list(tools, context: true, report_missing: false, capture_model: false)
|
|
125
|
+
options = Options.new(context: context, capture_model: capture_model)
|
|
126
|
+
prepared = tools.map do |tool|
|
|
127
|
+
next tool unless tool.is_a?(Hash) && (options.context_enabled? || options.capture_model_enabled?)
|
|
128
|
+
|
|
129
|
+
name = SchemaMutation.fetch(tool, :name) || 'unknown'
|
|
130
|
+
next tool if name == @missing_capability_tool_name
|
|
131
|
+
|
|
132
|
+
schema = SchemaMutation.fetch(tool, :inputSchema)
|
|
133
|
+
if options.context_enabled?
|
|
134
|
+
schema = SchemaMutation.add_context_parameter(schema, tool_name: name,
|
|
135
|
+
description: options.context_description)
|
|
136
|
+
end
|
|
137
|
+
if options.capture_model_enabled?
|
|
138
|
+
schema = SchemaMutation.add_model_parameter(schema, tool_name: name,
|
|
139
|
+
description: options.model_description)
|
|
140
|
+
end
|
|
141
|
+
tool.merge(SchemaMutation.key_for(tool, :inputSchema) => schema)
|
|
142
|
+
end
|
|
143
|
+
if report_missing && prepared.none? { |t| SchemaMutation.fetch(t, :name) == @missing_capability_tool_name }
|
|
144
|
+
prepared << Tools.descriptor(@missing_capability_tool_name, options)
|
|
145
|
+
end
|
|
146
|
+
prepared
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Pull the agent's intent off the `context` argument and its self-reported
|
|
150
|
+
# model off `llm_model`, strip the arguments this integration injected, and
|
|
151
|
+
# flag the `get_more_tools` virtual tool. Hand `intent`/`intent_source` and
|
|
152
|
+
# `llm_model`/`llm_model_source` straight to {#capture_tool_call}.
|
|
153
|
+
#
|
|
154
|
+
# Pass the tool's own `inputSchema` (the same Hash you handed to
|
|
155
|
+
# {#prepare_tool_list}) so a field the tool declares itself is left in
|
|
156
|
+
# `args` and never read as analytics: only an injected argument is stripped
|
|
157
|
+
# and reported. A composed (oneOf/allOf/anyOf) or `$ref` schema is never
|
|
158
|
+
# injected into, so its fields are the tool's own and are left alone too.
|
|
159
|
+
# Without the schema there is no way to tell the two apart, so the injected
|
|
160
|
+
# names are always stripped.
|
|
161
|
+
#
|
|
162
|
+
# @param name [String] tool name
|
|
163
|
+
# @param args [Hash, nil] the call's arguments
|
|
164
|
+
# @param input_schema [Hash, nil] the tool's raw `inputSchema`
|
|
165
|
+
# @return [PreparedToolCall]
|
|
166
|
+
def prepare_tool_call(name, args = nil, input_schema: nil)
|
|
167
|
+
intent = tool_declares?(input_schema, 'context') ? nil : Intent.normalize(argument(args, 'context'))
|
|
168
|
+
model = if tool_declares?(input_schema, ModelCapture::PARAM_NAME)
|
|
169
|
+
nil
|
|
170
|
+
else
|
|
171
|
+
ModelCapture.normalize(argument(args, ModelCapture::PARAM_NAME))
|
|
172
|
+
end
|
|
173
|
+
PreparedToolCall.new(
|
|
174
|
+
args: strip_injected(args, input_schema),
|
|
175
|
+
intent: intent,
|
|
176
|
+
intent_source: intent ? 'context_parameter' : nil,
|
|
177
|
+
llm_model: model,
|
|
178
|
+
llm_model_source: model ? 'self_reported' : nil,
|
|
179
|
+
is_missing_capability: name == @missing_capability_tool_name
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def base_event(event_type, distinct_id, session_id, set_properties, groups, properties, timestamp,
|
|
186
|
+
client_user_agent, vendor_client)
|
|
187
|
+
event = {
|
|
188
|
+
'event_type' => event_type,
|
|
189
|
+
'session_id' => session_id,
|
|
190
|
+
'timestamp' => timestamp || Time.now.utc,
|
|
191
|
+
'properties' => properties,
|
|
192
|
+
'groups' => groups,
|
|
193
|
+
'client_user_agent' => client_user_agent,
|
|
194
|
+
'vendor_client' => vendor_client
|
|
195
|
+
}
|
|
196
|
+
event['identify_actor_given_id'] = distinct_id if distinct_id.is_a?(String) && !distinct_id.empty?
|
|
197
|
+
event['identify_actor_data'] = set_properties if set_properties.is_a?(Hash) && !set_properties.empty?
|
|
198
|
+
event
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def apply_model(event, llm_model, source)
|
|
202
|
+
model = ModelCapture.normalize(llm_model)
|
|
203
|
+
return unless model
|
|
204
|
+
|
|
205
|
+
event['llm_model'] = model
|
|
206
|
+
event['llm_model_source'] = source || 'self_reported'
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def apply_intent(event, intent, source)
|
|
210
|
+
trimmed = intent.is_a?(String) ? intent.strip : ''
|
|
211
|
+
return if trimmed.empty?
|
|
212
|
+
|
|
213
|
+
event['user_intent'] = trimmed
|
|
214
|
+
event['user_intent_source'] = source || 'context_parameter'
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def emit(event)
|
|
218
|
+
@mcp_sink.capture(event, @mcp_options)
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# True when the argument belongs to the tool rather than to this layer: it
|
|
223
|
+
# declares the field itself, or its schema is one we never inject into.
|
|
224
|
+
def tool_declares?(input_schema, param)
|
|
225
|
+
return false unless input_schema
|
|
226
|
+
|
|
227
|
+
!SchemaMutation.injectable?(input_schema) || SchemaMutation.declares_param?(input_schema, param)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def argument(args, param)
|
|
231
|
+
return nil unless args.is_a?(Hash)
|
|
232
|
+
|
|
233
|
+
args[param.to_sym] || args[param]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def strip_injected(args, input_schema)
|
|
237
|
+
return args unless args.is_a?(Hash)
|
|
238
|
+
|
|
239
|
+
keys = ['context', ModelCapture::PARAM_NAME].reject { |param| tool_declares?(input_schema, param) }
|
|
240
|
+
.flat_map { |param| [param.to_sym, param] }
|
|
241
|
+
.select { |key| args.key?(key) }
|
|
242
|
+
keys.empty? ? args : args.except(*keys)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PostHog
|
|
4
|
+
module MCP
|
|
5
|
+
# Value of `$mcp_source` on every primary `$mcp_*` event.
|
|
6
|
+
SOURCE = 'posthog_mcp_analytics'
|
|
7
|
+
|
|
8
|
+
# `$lib` stamped on MCP analytics events (per event, never on the client).
|
|
9
|
+
LIB_NAME = 'posthog-ruby-mcp'
|
|
10
|
+
|
|
11
|
+
# Generated (in-memory) sessions roll over after this much inactivity.
|
|
12
|
+
INACTIVITY_TIMEOUT_MINUTES = 30
|
|
13
|
+
|
|
14
|
+
# Header carrying the transport session id, and our self-encoded token.
|
|
15
|
+
MCP_SESSION_HEADER = 'mcp-session-id'
|
|
16
|
+
|
|
17
|
+
# Description of the injected `context` argument.
|
|
18
|
+
DEFAULT_CONTEXT_PARAMETER_DESCRIPTION =
|
|
19
|
+
'Explain in 15-25 words, in third person, why this tool is called and how it supports ' \
|
|
20
|
+
"the user's goal. For analytics only. You MUST describe only the abstract purpose of the " \
|
|
21
|
+
'tool call. NEVER include, repeat, paraphrase, or infer personal, sensitive, or identifying ' \
|
|
22
|
+
'information from the user request or tool results, including names, emails, phone numbers, ' \
|
|
23
|
+
'IPs, IDs, or credentials. You MUST generalize specific entities into roles such as "a user", ' \
|
|
24
|
+
'"the customer", or "an account". Example: "Retrieving a customer\'s recent orders to ' \
|
|
25
|
+
'investigate a billing issue and help support determine the appropriate resolution."'
|
|
26
|
+
|
|
27
|
+
# Description of the injected `llm_model` argument.
|
|
28
|
+
DEFAULT_MODEL_PARAMETER_DESCRIPTION =
|
|
29
|
+
'The exact model identifier you (the assistant) are running as, taken from your system ' \
|
|
30
|
+
'prompt or environment (e.g. "claude-opus-4-8", "gpt-5.2"). Used for analytics only. If you ' \
|
|
31
|
+
'do not know your model identifier with certainty, pass "unknown" — never guess.'
|
|
32
|
+
|
|
33
|
+
# Description of the injected `conversation_id` argument.
|
|
34
|
+
DEFAULT_CONVERSATION_ID_DESCRIPTION =
|
|
35
|
+
"Echo the conversation_id from the server's previous response. The server provides it on " \
|
|
36
|
+
'the first call — never invent one, and do not issue parallel tool calls until you have it.'
|
|
37
|
+
|
|
38
|
+
# PostHog-owned event names. All `$`-prefixed per the PostHog convention.
|
|
39
|
+
module Event
|
|
40
|
+
CUSTOM = '$mcp_custom'
|
|
41
|
+
EXCEPTION = '$exception'
|
|
42
|
+
IDENTIFY = '$identify'
|
|
43
|
+
INITIALIZE = '$mcp_initialize'
|
|
44
|
+
MISSING_CAPABILITY = '$mcp_missing_capability'
|
|
45
|
+
PROMPT_GET = '$mcp_prompt_get'
|
|
46
|
+
PROMPTS_LIST = '$mcp_prompts_list'
|
|
47
|
+
RESOURCE_READ = '$mcp_resource_read'
|
|
48
|
+
RESOURCES_LIST = '$mcp_resources_list'
|
|
49
|
+
TOOL_CALL = '$mcp_tool_call'
|
|
50
|
+
TOOLS_LIST = '$mcp_tools_list'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# PostHog property wire keys emitted on MCP events.
|
|
54
|
+
module Property
|
|
55
|
+
CLIENT_NAME = '$mcp_client_name'
|
|
56
|
+
CLIENT_USER_AGENT = '$mcp_client_user_agent'
|
|
57
|
+
CLIENT_VERSION = '$mcp_client_version'
|
|
58
|
+
VENDOR_CLIENT = '$mcp_vendor_client'
|
|
59
|
+
PROTOCOL_VERSION = '$mcp_protocol_version'
|
|
60
|
+
CONVERSATION_ID = '$mcp_conversation_id'
|
|
61
|
+
DURATION_MS = '$mcp_duration_ms'
|
|
62
|
+
ERROR_MESSAGE = '$mcp_error_message'
|
|
63
|
+
ERROR_TYPE = '$mcp_error_type'
|
|
64
|
+
IS_ERROR = '$mcp_is_error'
|
|
65
|
+
INTENT = '$mcp_intent'
|
|
66
|
+
INTENT_SOURCE = '$mcp_intent_source'
|
|
67
|
+
LISTED_TOOL_NAMES = '$mcp_listed_tool_names'
|
|
68
|
+
LLM_MODEL = '$mcp_llm_model'
|
|
69
|
+
LLM_MODEL_SOURCE = '$mcp_llm_model_source'
|
|
70
|
+
PARAMETERS = '$mcp_parameters'
|
|
71
|
+
RESOURCE_NAME = '$mcp_resource_name'
|
|
72
|
+
RESPONSE = '$mcp_response'
|
|
73
|
+
SERVER_NAME = '$mcp_server_name'
|
|
74
|
+
SERVER_VERSION = '$mcp_server_version'
|
|
75
|
+
SESSION_ID = '$session_id'
|
|
76
|
+
SOURCE = '$mcp_source'
|
|
77
|
+
TOOL_CATEGORY = '$mcp_tool_category'
|
|
78
|
+
TOOL_DESCRIPTION = '$mcp_tool_description'
|
|
79
|
+
TOOL_NAME = '$mcp_tool_name'
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Internal dispatch keys for the event pipeline (never sent on the wire).
|
|
83
|
+
#
|
|
84
|
+
# @api private
|
|
85
|
+
module EventType
|
|
86
|
+
CUSTOM = 'posthog:custom'
|
|
87
|
+
IDENTIFY = 'posthog:identify'
|
|
88
|
+
MCP_INITIALIZE = 'mcp:initialize'
|
|
89
|
+
MCP_MISSING_CAPABILITY = 'mcp:missing_capability'
|
|
90
|
+
MCP_PROMPTS_GET = 'mcp:prompts/get'
|
|
91
|
+
MCP_PROMPTS_LIST = 'mcp:prompts/list'
|
|
92
|
+
MCP_RESOURCES_LIST = 'mcp:resources/list'
|
|
93
|
+
MCP_RESOURCES_READ = 'mcp:resources/read'
|
|
94
|
+
MCP_TOOLS_CALL = 'mcp:tools/call'
|
|
95
|
+
MCP_TOOLS_LIST = 'mcp:tools/list'
|
|
96
|
+
|
|
97
|
+
EVENT_NAME_BY_TYPE = {
|
|
98
|
+
CUSTOM => Event::CUSTOM,
|
|
99
|
+
IDENTIFY => Event::IDENTIFY,
|
|
100
|
+
MCP_INITIALIZE => Event::INITIALIZE,
|
|
101
|
+
MCP_MISSING_CAPABILITY => Event::MISSING_CAPABILITY,
|
|
102
|
+
MCP_PROMPTS_GET => Event::PROMPT_GET,
|
|
103
|
+
MCP_PROMPTS_LIST => Event::PROMPTS_LIST,
|
|
104
|
+
MCP_RESOURCES_LIST => Event::RESOURCES_LIST,
|
|
105
|
+
MCP_RESOURCES_READ => Event::RESOURCE_READ,
|
|
106
|
+
MCP_TOOLS_CALL => Event::TOOL_CALL,
|
|
107
|
+
MCP_TOOLS_LIST => Event::TOOLS_LIST
|
|
108
|
+
}.freeze
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PostHog
|
|
6
|
+
module MCP
|
|
7
|
+
# Optional `conversation_id` loop-back. When enabled, the SDK injects a
|
|
8
|
+
# `conversation_id` parameter into every tool, mints one when the agent does
|
|
9
|
+
# not supply it, hands it back on the response, and captures it as
|
|
10
|
+
# `$mcp_conversation_id`, stitching calls across reconnects and pods.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module ConversationId
|
|
14
|
+
PARAM_NAME = 'conversation_id'
|
|
15
|
+
MINTED_CONVERSATION_ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
16
|
+
|
|
17
|
+
MCP_INSTRUCTIONS_KEY = '_mcp_instructions'
|
|
18
|
+
INSTRUCTIONS_FIELD_DESCRIPTION = 'Server-issued metadata for this conversation.'
|
|
19
|
+
CONVERSATION_ID_FIELD_DESCRIPTION = 'The server-issued conversation identifier.'
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def normalize(value)
|
|
24
|
+
return nil unless value.is_a?(String)
|
|
25
|
+
|
|
26
|
+
trimmed = value.strip
|
|
27
|
+
trimmed.empty? ? nil : trimmed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def extract(args)
|
|
31
|
+
return nil unless args.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
normalize(args[PARAM_NAME] || args[PARAM_NAME.to_sym])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param supplied [String, nil] the `conversation_id` argument, and only
|
|
37
|
+
# when this layer owns it. A tool that declares `conversation_id` in its
|
|
38
|
+
# own schema is passed application data, which must not anchor analytics:
|
|
39
|
+
# two users sharing such a value would be stitched into one conversation.
|
|
40
|
+
# @return [Array(String, Boolean), Array(nil, false)] `[conversation_id, minted]`
|
|
41
|
+
def resolve(enabled, supplied, tool_name, missing_capability_tool_name)
|
|
42
|
+
return [nil, false] if !enabled || tool_name == missing_capability_tool_name
|
|
43
|
+
|
|
44
|
+
value = normalize(supplied)
|
|
45
|
+
return [value.downcase, false] if value && MINTED_CONVERSATION_ID.match?(value)
|
|
46
|
+
|
|
47
|
+
[Ids.uuid_v7, true]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def prompt_back?(result)
|
|
51
|
+
result.is_a?(Hash) && (result[:content] || result['content']).is_a?(Array)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Plain data, not an instruction: an instruction-shaped block is what a
|
|
55
|
+
# client's prompt-injection filter strips. Compact JSON.
|
|
56
|
+
def build_prompt_back(conversation_id)
|
|
57
|
+
{ type: 'text', text: JSON.generate({ conversation_id: conversation_id }) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Hash] a new result with the prompt-back appended (or the input unchanged)
|
|
61
|
+
def inject_prompt_back(result, conversation_id)
|
|
62
|
+
return result unless prompt_back?(result)
|
|
63
|
+
|
|
64
|
+
key = result.key?(:content) ? :content : 'content'
|
|
65
|
+
result.merge(key => result[key] + [build_prompt_back(conversation_id)])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Mirror the handle into `structuredContent` for tools whose output schema
|
|
69
|
+
# declared `_mcp_instructions`. Customer data wins when the key exists.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array(Object, Boolean)] `[result, delivered]`
|
|
72
|
+
def mirror_instructions(result, conversation_id)
|
|
73
|
+
return [result, false] unless result.is_a?(Hash)
|
|
74
|
+
|
|
75
|
+
key = %i[structuredContent structured_content].find { |k| result.key?(k) } ||
|
|
76
|
+
%w[structuredContent structured_content].find { |k| result.key?(k) }
|
|
77
|
+
return [result, false] if key.nil?
|
|
78
|
+
|
|
79
|
+
structured = result[key]
|
|
80
|
+
return [result, false] unless structured.is_a?(Hash)
|
|
81
|
+
return [result, false] if structured.key?(MCP_INSTRUCTIONS_KEY) || structured.key?(MCP_INSTRUCTIONS_KEY.to_sym)
|
|
82
|
+
|
|
83
|
+
payload = { 'conversation_id' => conversation_id }
|
|
84
|
+
instructions_key = structured.keys.first.is_a?(Symbol) ? MCP_INSTRUCTIONS_KEY.to_sym : MCP_INSTRUCTIONS_KEY
|
|
85
|
+
[result.merge(key => structured.merge(instructions_key => payload)), true]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|