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 +7 -0
- data/LICENSE +21 -0
- data/README.md +100 -0
- data/lib/mcpulse/buffer.rb +109 -0
- data/lib/mcpulse/canonical.rb +203 -0
- data/lib/mcpulse/emptiness.rb +98 -0
- data/lib/mcpulse/hashing.rb +45 -0
- data/lib/mcpulse/logger.rb +34 -0
- data/lib/mcpulse/options.rb +84 -0
- data/lib/mcpulse/stream.rb +117 -0
- data/lib/mcpulse/transport.rb +51 -0
- data/lib/mcpulse/version.rb +8 -0
- data/lib/mcpulse.rb +197 -0
- metadata +60 -0
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
|
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: []
|