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,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