mcpspan 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: 84a76f8debbf2cfc4830ae61f49f68e8bea556dee0176e1707415d19ec6fe7b9
4
+ data.tar.gz: b7ab651d3b894265c4a7dd21d5827b31f5996f85cb734759e4e1fb420cb17dc6
5
+ SHA512:
6
+ metadata.gz: 84bfdf6a1e69cb02d126641811e12730e72c2166fdb507c1d2dad5e2dd17cb26ddee618b9776742a6a63be2a79c1ae891cc6666409abfd08d9f41626a3c3dd3e
7
+ data.tar.gz: 235cbe6fbeae4a5e88c53048595c11dd8930a7a411a6c7c4fe449997393c9677181c26bc9d36e427d4f87f5efeab3c41d49114087c87cc1bdb68460e77ae8d3e
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kacper Zatoń
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,232 @@
1
+ # mcpspan for Ruby
2
+
3
+ Analytics for MCP servers. Find out which of your tools get called, by which
4
+ client, how long they take, and which ones fail.
5
+
6
+ Your own logs tell you a tool ran. This tells you whether it was Claude,
7
+ Cursor, or something you have not heard of, how that call compares to the
8
+ other nine hundred, and whether the failures are your tool breaking or your
9
+ tool politely saying no.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bundle add mcpspan
15
+ ```
16
+
17
+ For a server built on the official Ruby MCP SDK, the `mcp` gem, 1.6 or newer.
18
+ Needs Ruby 3.2 or newer. Nothing else: delivery uses the standard library.
19
+
20
+ ## Use
21
+
22
+ One line, after the server is built:
23
+
24
+ ```ruby
25
+ server = MCP::Server.new(name: "flights", tools: [SearchFlights, BookFlight])
26
+ McpSpan.instrument(server, api_key: ENV["MCPSPAN_API_KEY"], endpoint: "http://localhost:6271")
27
+
28
+ MCP::Server::Transports::StdioTransport.new(server).open
29
+ ```
30
+
31
+ Every tool on the server is measured, whether it was given to the server or
32
+ added later with `define_tool`. Nothing about how you write tools changes,
33
+ and each tool returns and raises exactly what it did before.
34
+
35
+ Over streamable HTTP, instrument the server you hand the transport:
36
+
37
+ ```ruby
38
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(McpSpan.instrument(server))
39
+ ```
40
+
41
+ With no `api_key`, it is read from `MCPSPAN_API_KEY`.
42
+
43
+ ### Sessions and clients
44
+
45
+ Calls are grouped into sessions when there is a connection to group them by:
46
+ a stdio process, or an HTTP transport that hands out session IDs. A stateless
47
+ HTTP endpoint, and every endpoint on the 2026-07-28 protocol, which dropped
48
+ sessions, records calls without one.
49
+
50
+ The client is read from the call itself on 2026-07-28, where each request
51
+ names its client, and from the handshake on 2025-11-25.
52
+
53
+ A tool that asks the client for more before it can finish is one call however
54
+ many round trips that takes.
55
+
56
+ ### Without a key
57
+
58
+ If there is no key, nothing is collected, nothing is sent, and no thread is
59
+ started. That makes it safe to leave in place in tests, in CI, and in a fork
60
+ somebody is only reading.
61
+
62
+ ### One tool at a time
63
+
64
+ For a server `instrument` does not cover, track the tool class:
65
+
66
+ ```ruby
67
+ McpSpan.track(SearchFlights)
68
+ ```
69
+
70
+ A tracked tool on an instrumented server is counted once.
71
+
72
+ ### Leaving a tool out
73
+
74
+ ```ruby
75
+ McpSpan.exclude(HealthCheck)
76
+ ```
77
+
78
+ For tools called by machinery rather than by an agent. A health check polled
79
+ every few seconds outnumbers everything a person does and drags the whole
80
+ server's error rate and response time towards its own. The mark sits on the
81
+ tool class, so a rename carries it along. A tool made with `define_tool` has
82
+ no class of its own, and is left out by name:
83
+
84
+ ```ruby
85
+ server.define_tool(name: McpSpan.exclude("health_check")) { MCP::Tool::Response.new([]) }
86
+ ```
87
+
88
+ ### Resources and prompts
89
+
90
+ Reads of your resources and gets of your prompts are measured too, with
91
+ nothing to add: each is one event, in the same session and from the same
92
+ client as the tool calls around it, and the dashboard shows them in a card of
93
+ their own and in each session's timeline. Listings are not recorded.
94
+
95
+ A resource at a fixed address is named by that address. One read through a
96
+ template is named by the template, `trips://{id}`, never by the address the
97
+ client asked for, which can carry a user's data; the template's variables are
98
+ its parameters, by name only. A read of an address the server has nothing for
99
+ is named by its scheme alone, `db://`. A prompt is named by its name, and its
100
+ arguments are its parameters, as a tool's are.
101
+
102
+ Resources and templates defined as classes, and ones answered by your own
103
+ `resources_read_handler`, are named alike.
104
+
105
+ ### Versions
106
+
107
+ Every call carries the version of the server that answered it, so the
108
+ dashboard marks where each release began and compares it with the one before.
109
+ There is nothing to add: it is the version the server gives itself,
110
+ `MCP::Server.new(name: "flights", version: "1.4.0")`. A server that gives none
111
+ is announced by the gem as `0.1.0`, and recorded so. To record a commit or a
112
+ deploy instead, set `server_version` (or `MCPSPAN_SERVER_VERSION`). The
113
+ client's version is recorded beside its name.
114
+
115
+ ### Shutting down
116
+
117
+ What is queued is delivered as the program exits, so most servers need
118
+ nothing here. If yours has its own shutdown path and you want to be explicit:
119
+
120
+ ```ruby
121
+ McpSpan.shutdown
122
+ ```
123
+
124
+ A process killed outright runs nothing after that, and the last few seconds
125
+ of calls go with it.
126
+
127
+ ## Two kinds of failure
128
+
129
+ MCP asks tools to report their own errors inside the result, with `error: true`
130
+ on the `MCP::Tool::Response`, so the model can see what went wrong. An
131
+ exception is the deviation from that, and usually means the tool broke.
132
+
133
+ Both are recorded, and each event says which happened, with the exception's
134
+ class for the second: a `BookingError` is recorded as `BookingError`, as your
135
+ tool raised it, though the MCP SDK hands the client a generic internal error.
136
+
137
+ ### And two that never reach your tool
138
+
139
+ Calls the server refuses on its own are recorded too: arguments that are
140
+ missing or fail the tool's input schema, and names it has no tool for. A
141
+ refused call carries no message, because validation text can quote back what
142
+ the agent sent. Whether a call reached your tool is observed directly, not
143
+ read from the server's wording.
144
+
145
+ ## Privacy
146
+
147
+ **Parameter values never leave your process.** Not by default, not in any
148
+ mode, not in debug.
149
+
150
+ What is collected: the tool name, how long it took, whether it succeeded, the
151
+ error type and a truncated message when it did not, which client called, and
152
+ the SDK version. For a resource or a prompt, the same, under the name it was
153
+ registered with: never the address a client read, only its template or, for
154
+ an address the server does not have, its scheme.
155
+
156
+ Optionally, parameter *names and types*:
157
+
158
+ ```ruby
159
+ McpSpan.instrument(server, capture_parameter_names: true)
160
+ ```
161
+
162
+ That records `{"destination": "string", "passengers": "number"}`, in JSON's
163
+ vocabulary, as the client sent them. Knowing `search_flights` is always
164
+ called with `destination` and never with `departure_date` tells you your tool
165
+ description is not landing. Knowing which destination tells you nothing you
166
+ needed, and puts your users' data somewhere it does not belong.
167
+
168
+ ## Self-hosting
169
+
170
+ ```ruby
171
+ McpSpan.instrument(server, endpoint: "https://mcpspan.example.com")
172
+ ```
173
+
174
+ Or set `MCPSPAN_ENDPOINT`. There is no default: events go only where you point
175
+ them. With a key and no endpoint, nothing is collected, and the SDK says so
176
+ once on standard error.
177
+
178
+ When it starts with a key, the SDK sends one empty batch to say it is there.
179
+ That is how the dashboard's Status page tells a server nobody has used yet
180
+ from one pointed at the wrong address, and how a wrong key is reported when
181
+ your server starts rather than at its first tool call.
182
+
183
+ ## It will not break your server
184
+
185
+ - Delivery runs on a thread of its own. A tool call returns without waiting
186
+ on the network. A forked worker, as Puma's are, starts delivering on its own
187
+ at its first call.
188
+ - A failure to send never reaches your code, and nothing here raises over a
189
+ setting. Retryable failures wait and try again with a widening gap; a
190
+ refused key switches collection off and says so once on standard error.
191
+ - The queue is bounded. An unreachable endpoint cannot grow it until your
192
+ process runs out of memory.
193
+ - Nothing is ever written to standard output, which carries the MCP protocol
194
+ on a stdio server. Diagnostics go to standard error.
195
+ - The MCP SDK has one `around_request` slot, and it is yours: mcpspan leaves
196
+ it alone. It hooks two private methods of the one server it instruments
197
+ instead, and if a version of the MCP SDK renames them, instrumenting leaves
198
+ the server as it was.
199
+
200
+ ## Options
201
+
202
+ Keywords of `McpSpan.instrument` and `McpSpan.configure`.
203
+
204
+ | Option | Default | What it does |
205
+ |---|---|---|
206
+ | `api_key` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
207
+ | `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
208
+ | `capture_parameter_names` | `false` | Records parameter names and types, never values. |
209
+ | `server_version` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
210
+ | `debug` | `false` | Writes delivery diagnostics to standard error. |
211
+ | `on_diagnostic` | - | Receives diagnostics instead. Implies `debug`. |
212
+ | `flush_on_exit` | `true` | Delivers what is queued as the program exits. |
213
+ | `flush_interval` | `5` seconds | How long a partly filled batch waits. |
214
+ | `max_batch_size` | `100` | Events per request. Reaching it sends early. |
215
+ | `max_queue_size` | `10000` | Events held while delivery is failing. |
216
+
217
+ Configuring again with the same settings changes nothing.
218
+
219
+ ## Developing
220
+
221
+ ```sh
222
+ bundle install
223
+ bundle exec rake
224
+ ```
225
+
226
+ The SDK follows [the contract every mcpspan SDK
227
+ follows](../../docs/sdk-contract.md), checked by the suite in
228
+ [`conformance/`](../../conformance/README.md).
229
+
230
+ ## Licence
231
+
232
+ MIT.
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module McpSpan
6
+ # The one configuration a process runs with, and recording calls under it.
7
+ module Collector
8
+ # Said when there is a key and nowhere to send: somebody meant to collect. There is no default endpoint, since
9
+ # mcpspan runs wherever its user runs it, and a default would send their data somewhere they did not choose.
10
+ NO_ENDPOINT = "mcpspan: an API key is set but no endpoint, so nothing is collected. Set MCPSPAN_ENDPOINT (or the " \
11
+ "endpoint option) to your mcpspan installation, for example http://localhost:6271."
12
+ # The API takes at most this many events in one request.
13
+ MAX_EVENTS_PER_REQUEST = 1_000
14
+
15
+ SETTINGS = %i[
16
+ api_key endpoint capture_parameter_names server_version debug on_diagnostic flush_on_exit flush_interval
17
+ max_batch_size max_queue_size
18
+ ].freeze
19
+
20
+ # What an integration knows about one call as it starts. A kind of nil is a tool call.
21
+ Call = Struct.new(:tool_name, :parameters, :client_name, :client_version, :server_version, :session_id, :started,
22
+ :timestamp, :kind, keyword_init: true,)
23
+
24
+ @lock = Mutex.new
25
+ @reporter = nil
26
+ @settings = nil
27
+ @at_exit = false
28
+ @said_no_endpoint = false
29
+
30
+ class << self
31
+ # Starts collecting, or stops if there is no key to collect with. The same settings again change nothing.
32
+ def configure(settings, sender: nil)
33
+ resolved = resolve(settings)
34
+ previous = @lock.synchronize do
35
+ return if @reporter && @settings == resolved
36
+
37
+ current = @reporter
38
+ @reporter = nil
39
+ @settings = nil
40
+ current
41
+ end
42
+ previous&.stop
43
+
44
+ # No key is a normal state, in development and CI, and not reported.
45
+ return if resolved[:api_key].empty?
46
+ return say_no_endpoint(resolved[:on_diagnostic]) if resolved[:endpoint].empty? && sender.nil?
47
+
48
+ reporter = Reporter.new(
49
+ endpoint: resolved[:endpoint],
50
+ send: sender || Transport.new(resolved[:endpoint], resolved[:api_key]).method(:call),
51
+ flush_interval: resolved[:flush_interval],
52
+ max_batch_size: resolved[:max_batch_size],
53
+ max_queue_size: resolved[:max_queue_size],
54
+ debug: resolved[:debug],
55
+ on_diagnostic: resolved[:on_diagnostic],
56
+ )
57
+ @lock.synchronize do
58
+ @reporter = reporter
59
+ @settings = resolved
60
+ end
61
+ at_exit_once if resolved[:flush_on_exit]
62
+ # In the background: startup does not wait for the network.
63
+ reporter.start
64
+ end
65
+
66
+ def shutdown
67
+ reporter = @lock.synchronize do
68
+ current = @reporter
69
+ @reporter = nil
70
+ @settings = nil
71
+ current
72
+ end
73
+ reporter&.stop
74
+ end
75
+
76
+ def collecting?
77
+ !@reporter.nil?
78
+ end
79
+
80
+ def flush
81
+ @reporter&.flush
82
+ end
83
+
84
+ # Notes the start of a call, or nil when nothing is being recorded. The clock is read first. A server version
85
+ # the SDK was told wins over the one the server gives itself.
86
+ def begin_call(tool_name, arguments:, session_id:, kind: nil, client_name: nil, client_version: nil,
87
+ server_version: nil)
88
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
89
+ timestamp = Time.now.utc
90
+ settings = @settings
91
+ return nil if settings.nil?
92
+
93
+ Call.new(
94
+ tool_name: tool_name.to_s,
95
+ parameters: settings[:capture_parameter_names] ? Text.describe_parameters(arguments) : nil,
96
+ client_name: client_name,
97
+ client_version: client_version,
98
+ server_version: settings[:server_version].empty? ? server_version : settings[:server_version],
99
+ session_id: session_id,
100
+ started: started,
101
+ timestamp: timestamp,
102
+ kind: kind,
103
+ )
104
+ end
105
+
106
+ # Builds the event for a finished call and queues it. It never blocks on the network.
107
+ def record(call, success:, source: nil, type: nil, message: nil)
108
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - call.started) * 1000.0
109
+ reporter = @reporter
110
+ return if reporter.nil?
111
+
112
+ reporter.record(Event.new(
113
+ id: SecureRandom.uuid,
114
+ kind: call.kind,
115
+ tool_name: Text.truncate(call.tool_name, Text::MAX_NAME),
116
+ duration_ms: duration_ms,
117
+ success: success,
118
+ error_source: source,
119
+ error_type: type && Text.truncate(type, Text::MAX_NAME),
120
+ error_message: message.nil? || message.empty? ? nil : message,
121
+ client_type: Text.client_type(call.client_name),
122
+ client_name: Text.client_name(call.client_name),
123
+ client_version: Text.version(call.client_version),
124
+ server_version: Text.version(call.server_version),
125
+ timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
126
+ session_id: call.session_id,
127
+ parameters: call.parameters,
128
+ ))
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ # For tests: forgets that the missing endpoint was already mentioned.
134
+ def forget_no_endpoint_notice
135
+ @lock.synchronize { @said_no_endpoint = false }
136
+ end
137
+
138
+ private
139
+
140
+ # Said unasked, as a refused key is: without it the data goes nowhere and nothing tells anyone.
141
+ def say_no_endpoint(on_diagnostic)
142
+ first = @lock.synchronize do
143
+ said = @said_no_endpoint
144
+ @said_no_endpoint = true
145
+ !said
146
+ end
147
+ return unless first
148
+
149
+ on_diagnostic ? on_diagnostic.call(NO_ENDPOINT) : warn(NO_ENDPOINT)
150
+ rescue StandardError
151
+ nil
152
+ end
153
+
154
+ def at_exit_once
155
+ @lock.synchronize do
156
+ return if @at_exit
157
+
158
+ @at_exit = true
159
+ end
160
+ # Runs when the program ends on its own, not on a signal it does not handle; that stays the program's own.
161
+ at_exit do
162
+ reporter = @reporter
163
+ reporter.stop if reporter && @settings&.fetch(:flush_on_exit)
164
+ rescue StandardError
165
+ nil
166
+ end
167
+ end
168
+
169
+ def resolve(settings)
170
+ debug = settings[:debug] ? true : !settings[:on_diagnostic].nil?
171
+ warn = lambda do |message|
172
+ next unless debug
173
+
174
+ settings[:on_diagnostic] ? settings[:on_diagnostic].call(message) : warn(message)
175
+ end
176
+ unknown = settings.keys - SETTINGS
177
+ warn.call("mcpspan: ignoring unknown settings #{unknown.join(", ")}") unless unknown.empty?
178
+
179
+ # A malformed setting falls back to its default, and says so when asked.
180
+ positive = lambda do |name, default, kind|
181
+ value = settings[name]
182
+ next default if value.nil?
183
+ next value if value.is_a?(kind) && value.positive?
184
+
185
+ warn.call("mcpspan: ignoring #{name}=#{value.inspect}, expected a positive number")
186
+ default
187
+ end
188
+ on_diagnostic = settings[:on_diagnostic]
189
+ on_diagnostic = nil unless on_diagnostic.respond_to?(:call)
190
+
191
+ {
192
+ api_key: first_set(settings[:api_key], ENV.fetch("MCPSPAN_API_KEY", nil)),
193
+ endpoint: first_set(settings[:endpoint], ENV.fetch("MCPSPAN_ENDPOINT", nil)),
194
+ capture_parameter_names: settings[:capture_parameter_names] ? true : false,
195
+ server_version: first_set(settings[:server_version], ENV.fetch("MCPSPAN_SERVER_VERSION", nil)),
196
+ debug: debug,
197
+ on_diagnostic: on_diagnostic,
198
+ flush_on_exit: settings.fetch(:flush_on_exit, true) ? true : false,
199
+ flush_interval: positive.call(:flush_interval, Reporter::DEFAULT_FLUSH_INTERVAL, Numeric).to_f,
200
+ max_batch_size: [positive.call(:max_batch_size, Reporter::DEFAULT_MAX_BATCH_SIZE, Integer),
201
+ MAX_EVENTS_PER_REQUEST,].min,
202
+ max_queue_size: positive.call(:max_queue_size, Reporter::DEFAULT_MAX_QUEUE_SIZE, Integer),
203
+ }
204
+ end
205
+
206
+ def first_set(*values)
207
+ values.map { |value| value.to_s.strip }.find { |value| !value.empty? } || ""
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module McpSpan
6
+ # One call of a tool, a resource or a prompt, in the shape the ingest API takes. Parameter values are never in it.
7
+ Event = Struct.new(
8
+ :id, :kind, :tool_name, :duration_ms, :success, :error_source, :error_type, :error_message,
9
+ :client_type, :client_name, :client_version, :server_version, :timestamp, :session_id, :parameters,
10
+ keyword_init: true,
11
+ ) do
12
+ # The event as the API takes it, leaving absent fields out rather than sending them as null.
13
+ def to_h
14
+ {
15
+ id: id,
16
+ kind: kind,
17
+ toolName: tool_name,
18
+ durationMs: duration_ms,
19
+ success: success,
20
+ errorSource: error_source,
21
+ errorType: error_type,
22
+ errorMessage: error_message,
23
+ clientType: client_type,
24
+ clientName: client_name,
25
+ clientVersion: client_version,
26
+ serverVersion: server_version,
27
+ timestamp: timestamp,
28
+ sdkVersion: VERSION,
29
+ sessionId: session_id,
30
+ parameters: parameters,
31
+ }.compact
32
+ end
33
+
34
+ # The body of one batch.
35
+ def self.batch(events)
36
+ JSON.generate({ events: events.map(&:to_h) })
37
+ end
38
+ end
39
+
40
+ # How a failed call announced itself, as the contract names it.
41
+ module Source
42
+ RESULT = "result"
43
+ EXCEPTION = "exception"
44
+ ARGUMENTS = "arguments"
45
+ UNKNOWN_TOOL = "unknown_tool"
46
+ UNKNOWN_RESOURCE = "unknown_resource"
47
+ UNKNOWN_PROMPT = "unknown_prompt"
48
+ end
49
+ end