mcpulse 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f94e04b6545f611b806eec5c45cb9eb372002ca6ab480166a53c2fae50b6b653
4
+ data.tar.gz: 72899707fa300d39372d397297e445f29f0032ca6c96a570e0b12f895695e1f1
5
+ SHA512:
6
+ metadata.gz: e81096f3940e671d6f1961312b82ca4aac82a72238f0f3acccf3348c4a1689ca30fffabb64b170ee36dec465d3363a627d553f3ec357ae0360f7549c4d200b94
7
+ data.tar.gz: f04005d8f6ee55d394c6eb29eba129f19d8cb2a8cb6281f6e851e39b7c69b66f44d32dd347e4f342cd57f8c25a1ac7bbe0e99ff63f01b44567460d71c7c4fd6a
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MCPulse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # mcpulse
2
+
3
+ Analytics for MCP servers, in Ruby.
4
+
5
+ ```ruby
6
+ require "mcpulse"
7
+
8
+ MCPulse.configure(key: "mp_live_…")
9
+
10
+ # Around your tool handler:
11
+ MCPulse.record("search", arguments, client_name: client) do
12
+ my_handler.call(arguments)
13
+ end
14
+ ```
15
+
16
+ Wrapping the handler rather than watching from outside is what lets MCPulse tell
17
+ a handler that raised from one that returned an error result — a distinction an
18
+ MCP server erases by converting both into `isError` before anything outside sees
19
+ it.
20
+
21
+ ## Install
22
+
23
+ ```ruby
24
+ gem "mcpulse"
25
+ ```
26
+
27
+ No runtime dependencies. This gem loads into other people's servers, and a
28
+ dependency that conflicts with what the customer already bundles is a support
29
+ burden with no upside for a single POST.
30
+
31
+ ## Options
32
+
33
+ | Keyword | Default | Meaning |
34
+ |---|---|---|
35
+ | `key:` | — | Ingest key, `mp_live_…`, minted per MCP in the dashboard |
36
+ | `endpoint:` | `https://api.getmcpulse.com` | Point at a local API while developing |
37
+ | `enabled:` | `true` | `false` makes everything a no-op — useful in tests and CI |
38
+ | `debug:` | `false` | Log what is sent, and why a send failed, to **stderr** |
39
+
40
+ An empty key turns it off, so a server started without its key configured is
41
+ silent rather than a source of 401s on every flush.
42
+
43
+ ## Reporting your tools
44
+
45
+ ```ruby
46
+ MCPulse.record_startup(tools_list_response, client_name: client)
47
+ ```
48
+
49
+ Pass the JSON your `tools/list` returns — `schema_bytes` is the cost of a tool's
50
+ presence in the context window, so it has to be measured on what actually goes
51
+ over the wire.
52
+
53
+ ## One known gap
54
+
55
+ `bad_args` is not reported. A server that validates arguments before calling the
56
+ handler rejects them outside the block, so the call never reaches `record`.
57
+ Reporting it anyway would mean reading the difference back out of an error
58
+ message, and error strings are not an interface anyone promised to keep. `ok`,
59
+ `tool_error` and `crashed` are all exact.
60
+
61
+ ## What leaves your process
62
+
63
+ Sizes and hashes. Arguments and results do not, and no option turns that on.
64
+
65
+ ## The three rules
66
+
67
+ 1. **Never raise.** Every entry point rescues. Your exception is re-raised
68
+ untouched; ours never reach you.
69
+ 2. **Never block.** A `Thread` with its own array rather than a sized `Queue` —
70
+ a bounded `Queue#push` blocks when full, which is exactly what must not
71
+ happen on the path a model is waiting on. This drops the oldest entry instead.
72
+ 3. **Never store customer data.** See above.
73
+
74
+ ## Cross-language consistency
75
+
76
+ `args_hash` is the first 12 hex characters of the SHA-256 of the
77
+ [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical form of the
78
+ arguments. `spec/fixtures/canonical.json` is the shared conformance suite every
79
+ MCPulse SDK runs.
80
+
81
+ Ruby needed three things undone: `Float#to_s` writes `1.0` and `1.0e-07` where
82
+ ECMAScript writes `1` and `1e-7`; `JSON.generate` leaves keys in insertion
83
+ order; and RFC 8785 sorts keys by UTF-16 code unit while Ruby compares UTF-8
84
+ bytes — the two disagree above the BMP, where U+1F680 (the surrogate pair D83D
85
+ DE80) sorts *before* U+FFFD.
86
+
87
+ Symbol-keyed hashes canonicalise identically to string-keyed ones, which matters
88
+ because that is what most Ruby MCP servers actually hold.
89
+
90
+ ## Running the tests
91
+
92
+ ```bash
93
+ ruby spec/run.rb
94
+ ```
95
+
96
+ No gems needed.
97
+
98
+ ## Licence
99
+
100
+ MIT
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'options'
4
+ require_relative 'transport'
5
+
6
+ module MCPulse
7
+ # Holds payloads and sends them in batches, on a thread of its own.
8
+ #
9
+ # The contract with the tool call that produced a payload is that +add+ returns immediately and
10
+ # never raises. Everything expensive happens on a background thread, so no model ever waits on
11
+ # MCPulse to answer.
12
+ #
13
+ # A Thread rather than a Fiber or a queue with a bound: a bounded +Queue#push+ blocks when full,
14
+ # which is exactly what must never happen on the path a model is waiting on. This keeps its own
15
+ # array and drops the oldest entry instead.
16
+ class PayloadBuffer
17
+ def initialize(options, log)
18
+ @options = options
19
+ @log = log
20
+
21
+ @mutex = Mutex.new
22
+ @wake = ConditionVariable.new
23
+ @pending = []
24
+ @closed = false
25
+ # Held for the duration of a batch, so a flush waits for a real send.
26
+ @sending = Mutex.new
27
+
28
+ @worker = Thread.new { run }
29
+ # Never hold the customer's process open over analytics.
30
+ @worker.abort_on_exception = false
31
+ end
32
+
33
+ # Buffers one payload. Returns immediately, never raises.
34
+ def add(payload)
35
+ ready = false
36
+
37
+ @mutex.synchronize do
38
+ return if @closed
39
+
40
+ if @pending.size >= Options::MAX_BUFFERED
41
+ # Oldest first: recent calls describe what the server is doing now, and that is the more
42
+ # useful half of a buffer that could not be sent.
43
+ @pending.shift
44
+ @log.call('buffer full, dropped oldest payload')
45
+ end
46
+
47
+ @pending << payload
48
+ ready = @pending.size >= Options::FLUSH_AT_ITEMS
49
+ @wake.signal if ready
50
+ end
51
+
52
+ nil
53
+ rescue StandardError
54
+ # Recording must never be the reason a tool call fails.
55
+ nil
56
+ end
57
+
58
+ # Final flush, best effort. After this the buffer accepts nothing more.
59
+ def close(timeout: Options::EXIT_FLUSH_SECONDS)
60
+ @mutex.synchronize do
61
+ return if @closed
62
+
63
+ @closed = true
64
+ @wake.broadcast
65
+ end
66
+
67
+ send_once
68
+ @worker.join(timeout)
69
+ nil
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ def run
77
+ loop do
78
+ @mutex.synchronize do
79
+ # Either something asked for a flush, or the five seconds elapsed.
80
+ @wake.wait(@mutex, Options::FLUSH_EVERY_SECONDS) if @pending.empty? && !@closed
81
+ end
82
+
83
+ send_once
84
+
85
+ break if @mutex.synchronize { @closed && @pending.empty? }
86
+ end
87
+ rescue StandardError => e
88
+ @log.call('sender stopped', e)
89
+ end
90
+
91
+ def send_once
92
+ @sending.synchronize do
93
+ batch = @mutex.synchronize do
94
+ next [] if @pending.empty?
95
+
96
+ # Taken in one go: anything added while this is in flight belongs to the next batch, not
97
+ # this one.
98
+ @pending.slice!(0, @pending.size)
99
+ end
100
+ return if batch.empty?
101
+
102
+ sent = Transport.post_batch(batch, @options)
103
+ @log.call("#{sent ? 'sent' : 'dropped'} #{batch.size} payloads")
104
+ end
105
+ rescue StandardError => e
106
+ @log.call('send failed', e)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPulse
4
+ # JSON Canonicalization Scheme (RFC 8785).
5
+ #
6
+ # +args_hash+ only means anything if every MCPulse SDK, in every language, turns the same
7
+ # arguments into the same bytes. Ruby's +JSON.generate+ does not get there on its own: it writes
8
+ # +1.0+ where ECMAScript writes +1+, it leaves keys in insertion order, and Ruby's own string
9
+ # ordering is by UTF-8 byte where JCS sorts by UTF-16 code unit. Each of those silently sends the
10
+ # same call to a different bucket than the TypeScript SDK would.
11
+ #
12
+ # So none of the serialisation below goes through +JSON+. Every rule is spelled out, and
13
+ # +spec/fixtures/canonical.json+ — the same file every other MCPulse SDK runs — is what holds
14
+ # this module to them.
15
+ module Canonical
16
+ # Raised for anything JSON cannot represent: a NaN, an infinity, a cycle, an unknown type.
17
+ class NotJSON < StandardError; end
18
+
19
+ SHORT_ESCAPES = {
20
+ "\b" => '\b',
21
+ "\t" => '\t',
22
+ "\n" => '\n',
23
+ "\f" => '\f',
24
+ "\r" => '\r',
25
+ '"' => '\"',
26
+ '\\' => '\\\\'
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # The canonical JSON form of +value+, as a UTF-8 String.
32
+ def canonicalize(value)
33
+ out = +''
34
+ write(out, value, [])
35
+ out
36
+ end
37
+
38
+ def write(out, value, seen)
39
+ case value
40
+ when nil then out << 'null'
41
+ when true then out << 'true'
42
+ when false then out << 'false'
43
+ when String, Symbol then write_string(out, value.to_s)
44
+ # Numeric covers Integer, Float, Rational and BigDecimal. Anything Float() cannot accept —
45
+ # a Complex, say — raises below and becomes a NotJSON, which is the right answer for it.
46
+ when Numeric then write_number(out, value)
47
+ when Array then write_array(out, value, seen)
48
+ when Hash then write_object(out, value, seen)
49
+ else
50
+ # A struct, or an object that knows how to become JSON. Round-trip it so it arrives here
51
+ # as one of the shapes above; the escaping its encoder applies on the way out is undone by
52
+ # the parse on the way back in, so it cannot leak into the canonical form.
53
+ write_via_json(out, value, seen)
54
+ end
55
+ end
56
+
57
+ def write_via_json(out, value, seen)
58
+ raise NotJSON, "cannot canonicalize #{value.class}" unless value.respond_to?(:to_json)
59
+
60
+ decoded = JSON.parse(value.to_json)
61
+ write(out, decoded, seen)
62
+ rescue JSON::JSONError, NoMethodError => e
63
+ raise NotJSON, "cannot canonicalize #{value.class}: #{e.message}"
64
+ end
65
+
66
+ def write_array(out, value, seen)
67
+ raise NotJSON, 'circular structure' if seen.any? { |item| item.equal?(value) }
68
+
69
+ seen.push(value)
70
+ out << '['
71
+ value.each_with_index do |item, index|
72
+ out << ',' if index.positive?
73
+ write(out, item, seen)
74
+ end
75
+ out << ']'
76
+ seen.pop
77
+ end
78
+
79
+ def write_object(out, value, seen)
80
+ raise NotJSON, 'circular structure' if seen.any? { |item| item.equal?(value) }
81
+
82
+ seen.push(value)
83
+ out << '{'
84
+ # Keys are carried alongside their original form, so a symbol-keyed Hash — which is what a
85
+ # Ruby MCP server most often has — still finds its values after sorting by the string.
86
+ sorted_entries(value).each_with_index do |(name, original), index|
87
+ out << ',' if index.positive?
88
+ write_string(out, name)
89
+ out << ':'
90
+ write(out, value[original], seen)
91
+ end
92
+ out << '}'
93
+ seen.pop
94
+ end
95
+
96
+ # Keys in RFC 8785 order: by UTF-16 code unit, not by UTF-8 byte.
97
+ #
98
+ # The two agree for everything in the Basic Multilingual Plane and disagree above it. U+1F680
99
+ # encodes as the surrogate pair D83D DE80, so JCS sorts it *before* U+FFFD while Ruby's default
100
+ # comparison puts it after. Encoding to UTF-16BE and comparing bytes is exactly the code-unit
101
+ # comparison JCS asks for.
102
+ def sorted_entries(hash)
103
+ hash.keys.map do |key|
104
+ unless key.is_a?(String) || key.is_a?(Symbol)
105
+ raise NotJSON, "object key must be a string, got #{key.class}"
106
+ end
107
+
108
+ [key.to_s, key]
109
+ end.sort_by { |(name, _original)| utf16_bytes(name) }
110
+ end
111
+
112
+ def utf16_bytes(text)
113
+ text.encode(Encoding::UTF_16BE, invalid: :replace, undef: :replace).bytes
114
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
115
+ text.bytes
116
+ end
117
+
118
+ # ─── Strings ───────────────────────────────────────────────────────────────
119
+
120
+ # A JSON string per JCS 3.2.2.2, which is ECMAScript's escaping: the short escapes where one
121
+ # exists, lowercase \u00xx for the rest of the C0 range, and nothing else touched.
122
+ #
123
+ # In particular non-ASCII is written literally, and so are <, > and &.
124
+ def write_string(out, text)
125
+ out << '"'
126
+ text.each_char do |char|
127
+ short = SHORT_ESCAPES[char]
128
+ if short
129
+ out << short
130
+ elsif char.ord < 0x20
131
+ out << format('\u%04x', char.ord)
132
+ else
133
+ out << char
134
+ end
135
+ end
136
+ out << '"'
137
+ end
138
+
139
+ # ─── Numbers ───────────────────────────────────────────────────────────────
140
+
141
+ # ECMAScript +Number::toString+, which is what JCS 3.2.2.3 defers to.
142
+ #
143
+ # Every number is treated as an IEEE-754 double, including Ruby's arbitrary-precision Integer:
144
+ # RFC 8785 limits JSON to double precision, and matching JavaScript is the entire point. An
145
+ # integer past 2**53 therefore loses precision here exactly as it would there, which is what
146
+ # keeps the two SDKs' hashes equal.
147
+ def write_number(out, value)
148
+ number = Float(value)
149
+ raise NotJSON, 'non-finite number' if number.nan? || number.infinite?
150
+
151
+ if number.zero?
152
+ # Covers -0.0, which JCS writes as "0".
153
+ out << '0'
154
+ return
155
+ end
156
+
157
+ if number.negative?
158
+ out << '-'
159
+ number = -number
160
+ end
161
+
162
+ digits, n = shortest(number)
163
+ k = digits.length
164
+
165
+ # The five cases of ECMAScript Number::toString, in its own order.
166
+ if k <= n && n <= 21
167
+ out << digits << ('0' * (n - k))
168
+ elsif n.positive? && n <= 21
169
+ out << digits[0, n] << '.' << digits[n..]
170
+ elsif n > -6 && n <= 0
171
+ out << '0.' << ('0' * -n) << digits
172
+ else
173
+ exponent = n - 1
174
+ out << (k == 1 ? digits : "#{digits[0]}.#{digits[1..]}")
175
+ out << 'e' << (exponent.negative? ? '-' : '+') << exponent.abs.to_s
176
+ end
177
+ rescue TypeError, RangeError => e
178
+ raise NotJSON, "number out of range: #{e.message}"
179
+ end
180
+
181
+ # Decomposes a positive finite double into its shortest round-tripping digits and the position
182
+ # of the decimal point: the value is +digits * 10**(n - digits.length)+.
183
+ #
184
+ # Ruby's +Float#to_s+ is already shortest-round-tripping, so this only has to re-read it — the
185
+ # exponent formatting is what differs between the two languages, not the digits.
186
+ def shortest(number)
187
+ formatted = number.to_s
188
+ mantissa, exponent_text = formatted.split(/e/i, 2)
189
+ exponent = exponent_text ? exponent_text.to_i : 0
190
+
191
+ integer_part, fraction_part = mantissa.split('.', 2)
192
+ fraction_part ||= ''
193
+ digits = "#{integer_part}#{fraction_part}"
194
+ n = exponent + integer_part.length
195
+
196
+ stripped = digits.sub(/\A0+/, '')
197
+ n -= digits.length - stripped.length
198
+ digits = stripped.sub(/0+\z/, '')
199
+
200
+ digits.empty? ? ['0', 1] : [digits, n]
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module MCPulse
6
+ # Did this call succeed while returning nothing useful?
7
+ #
8
+ # This is the metric that catches the failures nobody reports: a search that finds no rows, a
9
+ # lookup that misses, a query that comes back +[]+. The protocol calls all of those success, the
10
+ # model gets nothing it can use, and the author never hears about it.
11
+ #
12
+ # Only ever asked of a call that already succeeded — an error has its own outcome and is not also
13
+ # "empty".
14
+ module Emptiness
15
+ module_function
16
+
17
+ def empty_result?(result)
18
+ return true if result.nil?
19
+
20
+ structured = member(result, 'structuredContent')
21
+ unless structured.nil?
22
+ inner = unwrap_result_envelope(structured)
23
+ # What comes out of the envelope is whatever the tool returned. When that is a string it
24
+ # gets the same reading a text part does.
25
+ return inner.is_a?(String) ? hollow_text?(inner) : hollow?(inner)
26
+ end
27
+
28
+ content = member(result, 'content')
29
+ return empty_content?(content) if content.is_a?(Array)
30
+
31
+ # Not a tool result shape at all — judge the thing itself.
32
+ hollow?(result)
33
+ end
34
+
35
+ # Undoes a single-key <tt>{"result" => …}</tt> wrapper.
36
+ #
37
+ # SDKs that derive an output schema from a handler's return type wrap a non-object return: a
38
+ # tool that returns +"[]"+ arrives as <tt>{"result" => "[]"}</tt>. Judging the envelope would
39
+ # quietly kill this metric — every result would be a Hash with one key, so nothing would ever
40
+ # be empty, and the one thing +is_empty+ exists to catch would never fire.
41
+ def unwrap_result_envelope(structured)
42
+ return structured unless structured.is_a?(Hash) && structured.size == 1
43
+
44
+ structured['result'] || structured[:result] || structured
45
+ end
46
+
47
+ # MCP returns content as a list of parts.
48
+ #
49
+ # No parts is empty. One text part is the common case, and it is empty when the text is blank
50
+ # or when the text is itself a serialised empty collection — +"[]"+ is the single most common
51
+ # way a tool says "nothing found" while reporting success.
52
+ def empty_content?(content)
53
+ return true if content.empty?
54
+ return false if content.size > 1
55
+
56
+ part = content.first
57
+ return false unless member(part, 'type') == 'text'
58
+
59
+ text = member(part, 'text')
60
+ text.is_a?(String) && hollow_text?(text)
61
+ end
62
+
63
+ def hollow_text?(text)
64
+ trimmed = text.strip
65
+ return true if trimmed.empty?
66
+
67
+ hollow?(JSON.parse(trimmed))
68
+ rescue JSON::ParserError
69
+ # Prose, not JSON. A tool that answers in a sentence has said something.
70
+ false
71
+ end
72
+
73
+ # Empty array, empty hash, blank string, or nothing at all.
74
+ def hollow?(value)
75
+ case value
76
+ when nil then true
77
+ when String then value.strip.empty?
78
+ when Array, Hash then value.empty?
79
+ else
80
+ # A number or a boolean is an answer. 0 and false are results, not absences, and counting
81
+ # them as empty would report working tools as broken.
82
+ false
83
+ end
84
+ end
85
+
86
+ # Reads a named member off a Hash with either key type, or off an object with a reader.
87
+ def member(value, name)
88
+ case value
89
+ when nil then nil
90
+ when Hash then value[name].nil? ? value[name.to_sym] : value[name]
91
+ else
92
+ value.respond_to?(name) ? value.public_send(name) : nil
93
+ end
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+
6
+ require_relative 'canonical'
7
+
8
+ module MCPulse
9
+ # Fingerprinting a call's arguments.
10
+ module Hashing
11
+ # What an argument set hashes to when it cannot be serialised at all.
12
+ UNHASHABLE = '000000000000'
13
+
14
+ module_function
15
+
16
+ # A short, one-way fingerprint of a call's arguments.
17
+ #
18
+ # This is the only thing MCPulse ever learns about what was passed to a tool, and it is
19
+ # deliberately not enough to learn anything: 12 hex characters of a SHA-256 over the RFC 8785
20
+ # canonical form, with no way back. All the product asks of it is "were these two calls made
21
+ # with the same arguments or different ones" — which is what separates a model retrying a
22
+ # reworded request from a client paging through results.
23
+ def args_hash(args)
24
+ # A tool that takes no arguments is called with +arguments+ absent. That is an ordinary
25
+ # call, not a failure, and it hashes as the empty object it is — otherwise every no-argument
26
+ # tool shares one hash with every call whose arguments blew up.
27
+ value = args.nil? ? {} : args
28
+
29
+ Digest::SHA256.hexdigest(Canonical.canonicalize(value))[0, 12]
30
+ rescue StandardError
31
+ # Arguments JSON cannot represent. The call still happened and still deserves a row; it
32
+ # simply cannot be compared to another, so give it a constant that says exactly that.
33
+ UNHASHABLE
34
+ end
35
+
36
+ # Identifies one run of the customer's server, so calls can be grouped and a cost-per-session
37
+ # worked out.
38
+ #
39
+ # Random rather than derived — there is nothing about the process worth encoding here, and
40
+ # anything derived from the machine would be an identifier we did not intend to collect.
41
+ def new_session_id
42
+ "s_#{SecureRandom.hex(6)}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPulse
4
+ # Debug output, on stderr.
5
+ #
6
+ # stdout is the transport for a stdio MCP server — a single stray line there corrupts the
7
+ # JSON-RPC stream and takes the customer's server down with it. This is the one thing in the gem
8
+ # that would be trivially easy to get wrong and catastrophic to ship, so it goes through one
9
+ # place.
10
+ #
11
+ # Deliberately not +Logger+ or a Rails logger: a library that writes through the host's
12
+ # configuration can end up on stdout because of a setting it never saw.
13
+ class DebugLog
14
+ def initialize(debug)
15
+ @debug = debug
16
+ end
17
+
18
+ def call(message, detail = nil)
19
+ return unless @debug
20
+
21
+ suffix = detail.nil? ? '' : " #{format_detail(detail)}"
22
+ warn("[mcpulse] #{message}#{suffix}")
23
+ rescue StandardError
24
+ # Logging is never worth an exception.
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ def format_detail(detail)
31
+ detail.is_a?(Exception) ? "#{detail.class}: #{detail.message}" : detail.to_s
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPulse
4
+ # Everything +MCPulse.watch+ accepts, and what it means when you leave it out.
5
+ class Options
6
+ # Where payloads go when no endpoint is given.
7
+ DEFAULT_ENDPOINT = 'https://api.getmcpulse.com'
8
+
9
+ # Flush when either is reached, whichever comes first.
10
+ FLUSH_AT_ITEMS = 30
11
+ FLUSH_EVERY_SECONDS = 5.0
12
+
13
+ # Hard ceiling on the buffer. Reached only when the network is gone; past it the oldest
14
+ # payloads are dropped, because a customer's server running out of memory over our analytics
15
+ # is the one failure we must never cause.
16
+ MAX_BUFFERED = 1000
17
+
18
+ # Best-effort window for the final flush on the way out, and the cap on one batch.
19
+ EXIT_FLUSH_SECONDS = 1.0
20
+ SEND_TIMEOUT_SECONDS = 10.0
21
+
22
+ # Caps, so one malformed name cannot bloat a batch.
23
+ MAX_TOOL_NAME = 200
24
+ MAX_CLIENT_NAME = 128
25
+ MAX_TOOLS = 500
26
+
27
+ attr_reader :key, :endpoint
28
+
29
+ def initialize(key:, endpoint: nil, enabled: true, debug: false)
30
+ @key = key.is_a?(String) ? key.strip : ''
31
+ target = endpoint.is_a?(String) && !endpoint.strip.empty? ? endpoint : DEFAULT_ENDPOINT
32
+ @endpoint = target.sub(%r{/+\z}, '')
33
+ @enabled = enabled
34
+ @debug = debug
35
+ end
36
+
37
+ def debug?
38
+ @debug
39
+ end
40
+
41
+ # Whether anything should be recorded at all.
42
+ #
43
+ # An empty key turns the SDK off: a server started without its key configured should be
44
+ # silent, not a source of 401s on every flush.
45
+ def active?
46
+ @enabled && !@key.empty?
47
+ end
48
+
49
+ def stream_key
50
+ "#{@endpoint}|#{@key}"
51
+ end
52
+ end
53
+
54
+ # How a tool call ended. Exactly one of these, always.
55
+ module Outcome
56
+ OK = 'ok'
57
+ BAD_ARGS = 'bad_args'
58
+ TOOL_ERROR = 'tool_error'
59
+ CRASHED = 'crashed'
60
+
61
+ ALL = [OK, BAD_ARGS, TOOL_ERROR, CRASHED].freeze
62
+ end
63
+
64
+ # How MCPulse measures what a payload costs a context window.
65
+ module Sizes
66
+ module_function
67
+
68
+ # The length of +text+ in UTF-16 code units.
69
+ #
70
+ # +response_bytes+ and +schema_bytes+ are, today, what JavaScript's +String.length+ returns —
71
+ # code units, not bytes. The fields are named for bytes and hold code units, so "café" measures
72
+ # 4 and an emoji measures 2.
73
+ #
74
+ # That is a known wart in the wire format, and fixing it is a pending decision. Until it is
75
+ # made, every port reproduces the TypeScript behaviour rather than each inventing its own,
76
+ # because the whole value of these numbers is that they are comparable across a customer's
77
+ # servers. When the wire fixes it, this method is the one line that changes.
78
+ def utf16_length(text)
79
+ text.encode(Encoding::UTF_16BE, invalid: :replace, undef: :replace).bytesize / 2
80
+ rescue StandardError
81
+ text.length
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'buffer'
4
+ require_relative 'hashing'
5
+
6
+ module MCPulse
7
+ # One session and one buffer per destination, for the life of the process.
8
+ #
9
+ # The obvious shape is to make both where the server is built, which is right for a stdio server
10
+ # — one process, one server, one session — and wrong for an HTTP one. A streamable-HTTP server
11
+ # builds a fresh handler per request, and every tool call would become a session of its own.
12
+ #
13
+ # That is not a cosmetic difference. Retries are found by looking for the same tool twice inside
14
+ # one session, and first-call success is defined as no retry following. With one call per session
15
+ # there can never be a retry, so the server reports a perfect score however badly it is doing —
16
+ # the one number this product exists to tell the truth about.
17
+ #
18
+ # Keyed by endpoint and key rather than a bare singleton: two watched servers reporting to
19
+ # different MCPs in one process are two different streams, and merging them would file one
20
+ # customer's calls under another's.
21
+ class Stream
22
+ attr_reader :session_id, :buffer, :log
23
+
24
+ # Diverts payloads away from the buffer. Only tests set it.
25
+ class << self
26
+ attr_accessor :sink
27
+ end
28
+
29
+ def initialize(options, log)
30
+ @session_id = Hashing.new_session_id
31
+ @buffer = PayloadBuffer.new(options, log)
32
+ @log = log
33
+ @client_name = 'unknown'
34
+ @startup_sent = false
35
+ @mutex = Mutex.new
36
+ end
37
+
38
+ # Whoever most recently identified themselves.
39
+ #
40
+ # One value per process per destination, last identification wins. For a server with two
41
+ # concurrent clients that is an approximation, but it is the same approximation the shared
42
+ # session already makes, and a name that is occasionally the other client's beats a column
43
+ # that is always "unknown".
44
+ def client_name
45
+ @mutex.synchronize { @client_name }
46
+ end
47
+
48
+ def remember_client(name)
49
+ return if name.nil? || !name.is_a?(String) || name.empty?
50
+
51
+ @mutex.synchronize { @client_name = name[0, Options::MAX_CLIENT_NAME] }
52
+ end
53
+
54
+ def claim_startup
55
+ @mutex.synchronize do
56
+ return false if @startup_sent
57
+
58
+ @startup_sent = true
59
+ true
60
+ end
61
+ end
62
+
63
+ # Hands one payload to the buffer, or to a test's capture.
64
+ def emit(payload)
65
+ capture = self.class.sink
66
+ return capture.call(payload) if capture
67
+
68
+ @buffer.add(payload)
69
+ end
70
+
71
+ @streams = {}
72
+ @registry_mutex = Mutex.new
73
+ @hook_registered = false
74
+
75
+ class << self
76
+ def for(options, log)
77
+ @registry_mutex.synchronize do
78
+ existing = @streams[options.stream_key]
79
+ return existing if existing
80
+
81
+ created = new(options, log)
82
+ @streams[options.stream_key] = created
83
+
84
+ unless @hook_registered
85
+ # Registered once for the whole gem, however many servers are watched. at_exit runs
86
+ # before the interpreter tears the threads down, which is what makes the final flush
87
+ # possible at all.
88
+ @hook_registered = true
89
+ at_exit { flush_all }
90
+ end
91
+
92
+ created
93
+ end
94
+ end
95
+
96
+ # One last flush on the way out, so the final few calls of a session are not lost.
97
+ def flush_all
98
+ pending = @registry_mutex.synchronize do
99
+ taken = @streams.values
100
+ @streams = {}
101
+ taken
102
+ end
103
+
104
+ pending.each do |stream|
105
+ stream.buffer.close
106
+ rescue StandardError
107
+ nil
108
+ end
109
+ end
110
+
111
+ # Only tests reach for this.
112
+ def reset!
113
+ @registry_mutex.synchronize { @streams = {} }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'options'
8
+
9
+ module MCPulse
10
+ # Posting one batch.
11
+ #
12
+ # +net/http+ from the standard library rather than Faraday or HTTParty: this gem loads into other
13
+ # people's servers, and a dependency that conflicts with what the customer already bundles is a
14
+ # support burden with no upside for a single POST. It runs on a background thread, so blocking
15
+ # here costs nothing.
16
+ module Transport
17
+ module_function
18
+
19
+ # Sends one batch and reports whether it landed. Never raises — a caller must not have to
20
+ # rescue.
21
+ #
22
+ # A failed batch is dropped, deliberately. Retrying means either a queue that grows while the
23
+ # network is down, or duplicate rows when a 202 is lost on the way back. Neither is worth it
24
+ # for analytics: the next flush is five seconds away, and a gap in a chart is a far smaller
25
+ # problem than memory growth inside someone else's server.
26
+ def post_batch(payloads, options, timeout: Options::SEND_TIMEOUT_SECONDS)
27
+ return true if payloads.empty?
28
+
29
+ uri = URI.parse("#{options.endpoint}/v1/ingest")
30
+ body = JSON.generate({ batch: payloads })
31
+
32
+ http = Net::HTTP.new(uri.host, uri.port)
33
+ http.use_ssl = uri.scheme == 'https'
34
+ http.open_timeout = timeout
35
+ http.read_timeout = timeout
36
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
37
+
38
+ request = Net::HTTP::Post.new(uri.request_uri)
39
+ request['content-type'] = 'application/json'
40
+ request['authorization'] = "Bearer #{options.key}"
41
+ request['user-agent'] = 'mcpulse-ruby'
42
+ request.body = body
43
+
44
+ response = http.request(request)
45
+ response.code.to_i.between?(200, 299)
46
+ rescue StandardError
47
+ # DNS, TLS, a timeout, a proxy that hung up. All the same to us.
48
+ false
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPulse
4
+ VERSION = '0.1.0'
5
+
6
+ # Bumped only for a breaking change; the API rejects anything else.
7
+ WIRE_VERSION = 1
8
+ end
data/lib/mcpulse.rb ADDED
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'mcpulse/canonical'
6
+ require_relative 'mcpulse/emptiness'
7
+ require_relative 'mcpulse/hashing'
8
+ require_relative 'mcpulse/logger'
9
+ require_relative 'mcpulse/options'
10
+ require_relative 'mcpulse/stream'
11
+ require_relative 'mcpulse/version'
12
+
13
+ # MCPulse — analytics for MCP servers.
14
+ #
15
+ # MCPulse.configure(key: "mp_live_…")
16
+ #
17
+ # MCPulse.record("search", arguments) do
18
+ # my_handler.call(arguments)
19
+ # end
20
+ #
21
+ # Three rules this gem keeps, in order of how badly it would hurt to break one:
22
+ #
23
+ # 1. *Never raise.* Every entry point rescues. If MCPulse fails inside a customer's tool call,
24
+ # their tool fails and they blame us.
25
+ # 2. *Never block.* Record, buffer, return. Nothing waits on the network on the path a model is
26
+ # waiting on.
27
+ # 3. *Never store customer data.* Sizes and hashes leave this process. Arguments and results do
28
+ # not, and no option turns that off.
29
+ module MCPulse
30
+ class << self
31
+ # Starts recording, or turns everything into a no-op if the options say not to.
32
+ #
33
+ # Idempotent: calling it twice reuses the same session rather than opening a second one. Left
34
+ # unguarded, a server built per request would report every call under two sessions and double
35
+ # both the customer's numbers and their bill.
36
+ def configure(key:, endpoint: nil, enabled: true, debug: false)
37
+ options = Options.new(key: key, endpoint: endpoint, enabled: enabled, debug: debug)
38
+ log = DebugLog.new(options.debug?)
39
+
40
+ unless options.active?
41
+ log.call('disabled — no key, or enabled: false')
42
+ @stream = nil
43
+ return nil
44
+ end
45
+
46
+ @stream = Stream.for(options, log)
47
+ log.call('watching')
48
+ @stream
49
+ rescue StandardError
50
+ # Deliberately silent. Failing here must look like configure was never called, and there is
51
+ # no logger to complain to if the options were the thing that was malformed.
52
+ @stream = nil
53
+ end
54
+
55
+ # The session calls are being filed under, or nil when recording is off.
56
+ #
57
+ # Exposed so a server can log which session it joined, and so the shared-session guarantee can
58
+ # be asserted rather than assumed.
59
+ def session_id
60
+ @stream&.session_id
61
+ end
62
+
63
+ # Notes who is connected, so calls can be attributed to a client.
64
+ def remember_client(name)
65
+ @stream&.remember_client(name)
66
+ nil
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
71
+ # Times one tool call and buffers the result.
72
+ #
73
+ # The block's return value is handed back untouched and an exception is re-raised untouched,
74
+ # so a recorded call behaves exactly like an unrecorded one.
75
+ #
76
+ # Wrapping the handler rather than watching from outside is what lets MCPulse tell a handler
77
+ # that raised from one that returned an error result — a distinction an MCP server erases by
78
+ # converting both into +isError+ before anything outside can see it.
79
+ def record(tool_name, arguments = nil, client_name: nil)
80
+ stream = @stream
81
+ return yield if stream.nil?
82
+
83
+ stream.remember_client(client_name)
84
+
85
+ started_at = Time.now.utc
86
+ started = monotonic
87
+
88
+ result = nil
89
+ raised = false
90
+ begin
91
+ result = yield
92
+ result
93
+ rescue StandardError, ScriptError
94
+ raised = true
95
+ # Re-raised untouched: swallowing it would change what the customer's server does.
96
+ raise
97
+ ensure
98
+ begin
99
+ emit_call(stream, tool_name, arguments, result, raised, started_at, started)
100
+ rescue StandardError
101
+ # Recording must never be the reason a tool call fails.
102
+ nil
103
+ end
104
+ end
105
+ end
106
+
107
+ # Reports the server's tool list, once per session.
108
+ #
109
+ # +schema_bytes+ is the cost of a tool's presence in the context window, so pass the JSON that
110
+ # actually goes over the wire — what +tools/list+ returns — not the Ruby object the tool was
111
+ # declared from.
112
+ def record_startup(tools, client_name: nil)
113
+ stream = @stream
114
+ return nil if stream.nil? || !stream.claim_startup
115
+
116
+ stream.remember_client(client_name)
117
+
118
+ described = Array(tools).first(Options::MAX_TOOLS).filter_map do |tool|
119
+ name = Emptiness.member(tool, 'name')
120
+ next nil unless name.is_a?(String) && !name.empty?
121
+
122
+ { name: name[0, Options::MAX_TOOL_NAME], schema_bytes: measure(tool) }
123
+ end
124
+
125
+ stream.emit(
126
+ v: WIRE_VERSION,
127
+ type: 'startup',
128
+ session_id: stream.session_id,
129
+ client_name: stream.client_name,
130
+ tools: described
131
+ )
132
+ stream.log.call("startup: #{described.size} tools, client #{stream.client_name}")
133
+ nil
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ # Sends everything buffered and stops accepting more.
139
+ #
140
+ # An +at_exit+ hook already does this. Call it by hand only when the server stops without the
141
+ # process exiting — a test suite, or a host that restarts servers in place.
142
+ def flush_all
143
+ Stream.flush_all
144
+ end
145
+
146
+ private
147
+
148
+ def emit_call(stream, tool_name, arguments, result, raised, started_at, started)
149
+ outcome = decide_outcome(result, raised)
150
+ name = tool_name.to_s
151
+ name = 'unknown' if name.empty?
152
+
153
+ stream.emit(
154
+ v: WIRE_VERSION,
155
+ type: 'call',
156
+ session_id: stream.session_id,
157
+ client_name: stream.client_name,
158
+ tool_name: name[0, Options::MAX_TOOL_NAME],
159
+ started_at: started_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
160
+ duration_ms: ((monotonic - started) * 1000).round,
161
+ outcome: outcome,
162
+ response_bytes: measure(result),
163
+ # An error is not also an absence — it has its own outcome already.
164
+ is_empty: outcome == Outcome::OK && Emptiness.empty_result?(result),
165
+ args_hash: Hashing.args_hash(arguments)
166
+ )
167
+ end
168
+
169
+ # What the outcome was, given that the handler is what we wrapped.
170
+ #
171
+ # Wrapping the block means a raise arrives here as a raise rather than as the +isError+ result
172
+ # the server would have converted it into. What cannot be seen from here is +bad_args+: a
173
+ # server that validates arguments before calling the handler rejects them outside this block.
174
+ # Reporting it anyway would mean reading the difference back out of an error message, and error
175
+ # strings are not an interface anyone promised to keep.
176
+ def decide_outcome(result, raised)
177
+ return Outcome::CRASHED if raised
178
+
179
+ flag = Emptiness.member(result, 'isError')
180
+ flag = Emptiness.member(result, 'is_error') if flag.nil?
181
+ flag == true ? Outcome::TOOL_ERROR : Outcome::OK
182
+ end
183
+
184
+ # What something costs the context window. Unserialisable means unmeasurable.
185
+ def measure(value)
186
+ return 0 if value.nil?
187
+
188
+ Sizes.utf16_length(JSON.generate(value))
189
+ rescue StandardError
190
+ 0
191
+ end
192
+
193
+ def monotonic
194
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
195
+ end
196
+ end
197
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mcpulse
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - MCPulse
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |
14
+ See which of your MCP tools actually work for the models calling them.
15
+ Records how every tool call ended, how long it took, and whether it came
16
+ back empty. Arguments and results never leave your process.
17
+ email:
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/mcpulse.rb
25
+ - lib/mcpulse/buffer.rb
26
+ - lib/mcpulse/canonical.rb
27
+ - lib/mcpulse/emptiness.rb
28
+ - lib/mcpulse/hashing.rb
29
+ - lib/mcpulse/logger.rb
30
+ - lib/mcpulse/options.rb
31
+ - lib/mcpulse/stream.rb
32
+ - lib/mcpulse/transport.rb
33
+ - lib/mcpulse/version.rb
34
+ homepage: https://github.com/getmcpulse/mcpulse-ruby
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ source_code_uri: https://github.com/getmcpulse/mcpulse-ruby
39
+ bug_tracker_uri: https://github.com/getmcpulse/mcpulse-ruby/issues
40
+ rubygems_mfa_required: 'true'
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '3.0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 3.4.20
57
+ signing_key:
58
+ specification_version: 4
59
+ summary: Analytics for MCP servers. One import, one wrap.
60
+ test_files: []