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 +7 -0
- data/LICENSE +21 -0
- data/README.md +232 -0
- data/lib/mcpspan/collector.rb +211 -0
- data/lib/mcpspan/event.rb +49 -0
- data/lib/mcpspan/instrumentation.rb +216 -0
- data/lib/mcpspan/primitives.rb +179 -0
- data/lib/mcpspan/reporter.rb +224 -0
- data/lib/mcpspan/text.rb +72 -0
- data/lib/mcpspan/transport.rb +64 -0
- data/lib/mcpspan/version.rb +6 -0
- data/lib/mcpspan.rb +88 -0
- metadata +55 -0
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
|