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