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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 471bfa76731003cb33a963c903119506339f2e5cc51eb5b326a8af5df7deaabd
|
|
4
|
+
data.tar.gz: c41215d0fcc1edc5894b513788ca1e5777e98061f6017e80ae845c46c009d906
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f183202aacd689a033e142a57d79355b115306fc39538220c7b002828883d15c8c61cccd29f3a40c7fde95df2ddefd55cebc4f0eab8fdf5cb9a800887b2536bd
|
|
7
|
+
data.tar.gz: 02123c015cb06e724ab63576948dbf928c7ce1f919772c0e5b70d69d62e2b7ce27ccbbb8a16b24de583c608d8566f5c7da806a2756cf6a182a40d7ced73d466e
|
data/lib/posthog/client.rb
CHANGED
|
@@ -302,6 +302,11 @@ module PostHog
|
|
|
302
302
|
|
|
303
303
|
symbolize_keys! attrs
|
|
304
304
|
minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
|
|
305
|
+
# Integrations that ride on a host application's client (e.g. PostHog::MCP)
|
|
306
|
+
# may relabel a single event's `$lib`/`$lib_version` without relabeling the
|
|
307
|
+
# client or its User-Agent.
|
|
308
|
+
lib_override = attrs.delete(:_lib)
|
|
309
|
+
lib_version_override = attrs.delete(:_lib_version)
|
|
305
310
|
enrich_capture_attrs_with_context(attrs)
|
|
306
311
|
|
|
307
312
|
# Precedence: an explicit `flags` snapshot always wins, regardless of
|
|
@@ -370,8 +375,8 @@ module PostHog
|
|
|
370
375
|
end
|
|
371
376
|
|
|
372
377
|
attrs[:is_server] = @is_server
|
|
373
|
-
attrs[:lib] = @lib
|
|
374
|
-
attrs[:lib_version] = @lib_version
|
|
378
|
+
attrs[:lib] = lib_override || @lib
|
|
379
|
+
attrs[:lib_version] = (lib_version_override || @lib_version).to_s
|
|
375
380
|
message = FieldParser.parse_for_capture(attrs)
|
|
376
381
|
# Minimal events are built from the allowlist after full assembly so
|
|
377
382
|
# context properties and parser-added metadata can never leak in.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# PostHog MCP analytics for Ruby
|
|
2
|
+
|
|
3
|
+
> **Experimental and unsupported.** `PostHog::MCP` is not an officially supported PostHog SDK: no
|
|
4
|
+
> support is provided for it, and its API, options, and the captured `$mcp_*` event schema may change
|
|
5
|
+
> in a minor release. A one-line warning is logged when you require it. Bug reports and patches are
|
|
6
|
+
> welcome at https://github.com/PostHog/posthog-ruby/issues, but don't build production reporting on
|
|
7
|
+
> it yet.
|
|
8
|
+
|
|
9
|
+
Product analytics for [Model Context Protocol](https://modelcontextprotocol.io) servers built on the
|
|
10
|
+
official Ruby [`mcp`](https://rubygems.org/gems/mcp) gem. Wrap an `MCP::Server` so every tool call,
|
|
11
|
+
agent intent, handshake, listing, prompt, resource read, and failure is captured to PostHog.
|
|
12
|
+
|
|
13
|
+
**Documentation: https://posthog.com/docs/mcp-analytics** — setup, every option, the event and
|
|
14
|
+
property catalog, sessions on stateless/multi-pod servers, conversation ids, intent, identifying
|
|
15
|
+
users, privacy, and custom dispatchers. That's the single source of truth; this directory
|
|
16
|
+
deliberately keeps no second copy of it.
|
|
17
|
+
|
|
18
|
+
Install is just `gem 'posthog-ruby'`. `PostHog::MCP.instrument` needs the `mcp` gem (`>= 1.4`) at
|
|
19
|
+
runtime, but anyone wrapping a server already has it. `PostHog::MCP::Client` (custom dispatchers)
|
|
20
|
+
needs nothing beyond `posthog-ruby`.
|
|
21
|
+
|
|
22
|
+
## Ruby-specific notes
|
|
23
|
+
|
|
24
|
+
These are the few things that differ from what the docs describe for the other SDKs.
|
|
25
|
+
|
|
26
|
+
* **`$lib` is per event.** MCP events report `$lib: "posthog-ruby-mcp"` while the client you pass in
|
|
27
|
+
keeps its own identity (`posthog-ruby` or `posthog-rails`) for everything else it sends, so
|
|
28
|
+
instrumenting a server inside a Rails app doesn't relabel the app's other events.
|
|
29
|
+
* **Truncation is tighter.** Events are truncated to fit the core client's 32KB per-message limit,
|
|
30
|
+
because posthog-ruby drops larger messages at batch time - truncating harder beats losing the
|
|
31
|
+
event.
|
|
32
|
+
* **Request scope needs Ruby 3.2+ to be inherited.** A custom event captured from a thread or fiber
|
|
33
|
+
a tool spawns is attributed to that request on 3.2+ (fiber storage); before 3.2 capture from the
|
|
34
|
+
tool body itself, or an HTTP server falls back to a standalone session.
|
|
35
|
+
* **Composed schemas are left alone.** A tool whose `input_schema` is `oneOf`/`allOf`/`anyOf` or a
|
|
36
|
+
`$ref` has nothing injected into it and nothing stripped from its calls.
|
|
37
|
+
* **stdio servers own `$stdout`.** The integration's own messages go only to the `logger:` you pass
|
|
38
|
+
(default: nowhere); the experimental notice and misconfiguration warnings go to stderr. Point the
|
|
39
|
+
core SDK's logger away from stdout too: `PostHog::Logging.logger = Logger.new($stderr)`.
|
|
40
|
+
|
|
41
|
+
## Layout
|
|
42
|
+
|
|
43
|
+
| File | What it does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `mcp.rb` (parent dir) | `PostHog::MCP.instrument` and the public helpers |
|
|
46
|
+
| `server_extension.rb` | Prepended onto `MCP::Server` and the Streamable HTTP transport |
|
|
47
|
+
| `instrumentation.rb` | Per-request dispatch: intent, identity, sessions, recording |
|
|
48
|
+
| `client.rb` | `PostHog::MCP::Client` for custom dispatchers |
|
|
49
|
+
| `rack_middleware.rb` | `Mcp-Session-Id` tokens for a custom Rack stack |
|
|
50
|
+
| `sanitization.rb`, `truncation.rb` | Redaction and payload budgets |
|
|
51
|
+
|
|
52
|
+
Runnable example: [`examples/mcp_server.rb`](../../../examples/mcp_server.rb). Specs:
|
|
53
|
+
`spec/posthog/mcp/`.
|
|
@@ -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
|