posthog-ruby 3.23.8 → 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 +7 -2
- 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,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
|