mcpspan 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/lib/mcpspan/collector.rb +38 -1
- data/lib/mcpspan/definitions.rb +16 -5
- data/lib/mcpspan/event.rb +3 -1
- data/lib/mcpspan/instrumentation.rb +24 -10
- data/lib/mcpspan/listings.rb +35 -0
- data/lib/mcpspan/text.rb +2 -19
- data/lib/mcpspan/version.rb +1 -1
- data/lib/mcpspan.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25cc72fce303e0fbb6aa636d4a50bba9f2c10756df307d965aab48476a94fb36
|
|
4
|
+
data.tar.gz: 20b8cf13af0f6f24e9c40ac2ff0b4bdf9240c0f5ee7e7f436ec5b542c8f4f2d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fd550e46bba2af4c2d65bbaf6253f8835dd1773995fff00d1573422ca6eafda7fcb1a6c7c805009cb8ed2e45bbeb69625f46648a522fc25a7554032da283ffa
|
|
7
|
+
data.tar.gz: 43df35e2670e3c746984df1075d08219e6ffc51d4304d1e9cf26ad165b96d4bad91307eaffdb4239fed51bc29d99e24b53413c34add6f5dd6eaa4d9b0f1f8a94
|
data/README.md
CHANGED
|
@@ -164,7 +164,10 @@ tool's definition as your server lists it (its name, title, description and
|
|
|
164
164
|
input schema, hashed, so the dashboard can mark when you changed it), for a call your
|
|
165
165
|
server refused for its arguments the names of those that did not match the
|
|
166
166
|
tool's schema (never what was sent), which
|
|
167
|
-
client called, and the SDK version.
|
|
167
|
+
client called, and the SDK version. When your server answers a request for
|
|
168
|
+
its list of tools: each tool's name, that fingerprint and the size of its
|
|
169
|
+
definition in bytes, once per session, so the dashboard can show what the
|
|
170
|
+
list costs an agent and which tools are never called. For a resource or a prompt, the same, under the name it was
|
|
168
171
|
registered with: never the address a client read, only its template or, for
|
|
169
172
|
an address the server does not have, its scheme.
|
|
170
173
|
|
data/lib/mcpspan/collector.rb
CHANGED
|
@@ -139,7 +139,10 @@ module McpSpan
|
|
|
139
139
|
error_source: source,
|
|
140
140
|
error_type: type && Text.truncate(type, Text::MAX_NAME),
|
|
141
141
|
error_message: message.nil? || message.empty? ? nil : message,
|
|
142
|
-
|
|
142
|
+
# Always "unknown": the mcpspan server decides the client from its name
|
|
143
|
+
# (contract, 7). Sent only because servers released before 2026-10-15
|
|
144
|
+
# refuse an event without it.
|
|
145
|
+
client_type: "unknown",
|
|
143
146
|
client_name: Text.client_name(call.client_name),
|
|
144
147
|
client_version: Text.version(call.client_version),
|
|
145
148
|
server_version: Text.version(call.server_version),
|
|
@@ -156,6 +159,40 @@ module McpSpan
|
|
|
156
159
|
nil
|
|
157
160
|
end
|
|
158
161
|
|
|
162
|
+
# Tools in a listing beyond this are left out (contract, 3.11).
|
|
163
|
+
MAX_LISTED_TOOLS = 500
|
|
164
|
+
|
|
165
|
+
# Records what the server answered to `tools/list` (contract, 3.11): the tools' names, fingerprints and sizes,
|
|
166
|
+
# once per session for the same answer, leaving out the tools the block says are excluded. `call` carries the
|
|
167
|
+
# client, the session and the versions. It never blocks on the network.
|
|
168
|
+
def record_listing(call, tools)
|
|
169
|
+
reporter = @reporter
|
|
170
|
+
return if reporter.nil? || call.nil?
|
|
171
|
+
|
|
172
|
+
shown = tools.reject { |name, _, _| yield(name) }.first(MAX_LISTED_TOOLS).map do |name, hash, bytes|
|
|
173
|
+
{ name: Text.truncate(name, Text::MAX_NAME), definitionHash: hash, definitionBytes: bytes }
|
|
174
|
+
end
|
|
175
|
+
signature = shown.map { |tool| "#{tool[:name]}\0#{tool[:definitionHash]}" }.join("\0")
|
|
176
|
+
return unless Listings.first?(call.session_id, signature)
|
|
177
|
+
|
|
178
|
+
reporter.record(Event.new(
|
|
179
|
+
id: SecureRandom.uuid,
|
|
180
|
+
kind: "listing",
|
|
181
|
+
tool_name: "tools/list",
|
|
182
|
+
duration_ms: 0,
|
|
183
|
+
success: true,
|
|
184
|
+
client_type: "unknown",
|
|
185
|
+
client_name: Text.client_name(call.client_name),
|
|
186
|
+
client_version: Text.version(call.client_version),
|
|
187
|
+
server_version: Text.version(call.server_version),
|
|
188
|
+
timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
|
|
189
|
+
session_id: call.session_id,
|
|
190
|
+
listed_tools: shown,
|
|
191
|
+
))
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
159
196
|
# Which declared arguments a refusal was over (contract, 3.10), or nil when none were found.
|
|
160
197
|
def refused_names(call)
|
|
161
198
|
names = ArgumentChecks.invalid(Definitions.schema_of(call.tool_name), call.arguments)
|
data/lib/mcpspan/definitions.rb
CHANGED
|
@@ -29,19 +29,23 @@ module McpSpan
|
|
|
29
29
|
@lock.synchronize { @schemas[tool_name] }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Notes every tool in a listing, in whatever key style the gem keeps them
|
|
32
|
+
# Notes every tool in a listing, in whatever key style the gem keeps them, and returns them in its order as
|
|
33
|
+
# name, fingerprint and size (contract, 3.11). Never raises.
|
|
33
34
|
def note(tools)
|
|
35
|
+
found = []
|
|
34
36
|
JSON.parse(JSON.generate(Array(tools))).each do |tool|
|
|
35
37
|
next unless tool.is_a?(Hash) && tool["name"].is_a?(String)
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
written = fingerprint(tool)
|
|
38
40
|
@lock.synchronize do
|
|
39
|
-
@listed[tool["name"]] =
|
|
41
|
+
@listed[tool["name"]] = written[0] if written
|
|
40
42
|
@schemas[tool["name"]] = tool["inputSchema"]
|
|
41
43
|
end
|
|
44
|
+
found << [tool["name"], *written] if written
|
|
42
45
|
end
|
|
46
|
+
found
|
|
43
47
|
rescue StandardError
|
|
44
|
-
|
|
48
|
+
found || []
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# For tests: forgets every listing.
|
|
@@ -55,8 +59,15 @@ module McpSpan
|
|
|
55
59
|
# The first 16 hex characters of the SHA-256 of the tool's name, title, description and input schema, as
|
|
56
60
|
# canonical JSON; nil for a definition that cannot be written so.
|
|
57
61
|
def hash(tool)
|
|
62
|
+
fingerprint(tool)&.first
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The fingerprint, and the size in UTF-8 bytes of the canonical JSON it is taken from (contract, 3.11); nil for
|
|
66
|
+
# a definition that cannot be written so.
|
|
67
|
+
def fingerprint(tool)
|
|
58
68
|
hashed = HASHED.each_with_object({}) { |field, out| out[field] = tool[field] unless tool[field].nil? }
|
|
59
|
-
|
|
69
|
+
written = canonical(hashed)
|
|
70
|
+
[Digest::SHA256.hexdigest(written)[0, 16], written.bytesize]
|
|
60
71
|
rescue StandardError
|
|
61
72
|
nil
|
|
62
73
|
end
|
data/lib/mcpspan/event.rb
CHANGED
|
@@ -7,7 +7,7 @@ module McpSpan
|
|
|
7
7
|
Event = Struct.new(
|
|
8
8
|
:id, :kind, :tool_name, :duration_ms, :success, :error_source, :error_type, :error_message,
|
|
9
9
|
:client_type, :client_name, :client_version, :server_version, :response_bytes, :definition_hash, :repeated,
|
|
10
|
-
:invalid_arguments, :timestamp, :session_id, :parameters,
|
|
10
|
+
:invalid_arguments, :timestamp, :session_id, :parameters, :listed_tools,
|
|
11
11
|
keyword_init: true,
|
|
12
12
|
) do
|
|
13
13
|
# The event as the API takes it, leaving absent fields out rather than sending them as null.
|
|
@@ -33,6 +33,8 @@ module McpSpan
|
|
|
33
33
|
sdkVersion: VERSION,
|
|
34
34
|
sessionId: session_id,
|
|
35
35
|
parameters: parameters,
|
|
36
|
+
# On a listing: the tools the server listed, in its order (contract, 3.11).
|
|
37
|
+
listedTools: listed_tools,
|
|
36
38
|
}.compact
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -52,18 +52,32 @@ module McpSpan
|
|
|
52
52
|
original = handlers.is_a?(Hash) ? handlers[method] : nil
|
|
53
53
|
return unless original.respond_to?(:call) && original.respond_to?(:parameters)
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
contextual = original.parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :server_context }
|
|
56
|
+
# Always asking for the context, which is where the session and the client are; the gem's own handler gets
|
|
57
|
+
# it only if it asked for it too.
|
|
58
|
+
handlers[method] = lambda do |params, server_context: nil|
|
|
59
|
+
result = contextual ? original.call(params, server_context: server_context) : original.call(params)
|
|
60
|
+
record_listing(server, result, server_context) if Collector.collecting? && result.is_a?(Hash)
|
|
57
61
|
result
|
|
58
62
|
end
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
rescue StandardError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Notes a listing's fingerprints (contract, 3.8) and records it (3.11). Never raises.
|
|
68
|
+
def record_listing(server, result, server_context)
|
|
69
|
+
tools = Definitions.note(result[:tools] || result["tools"])
|
|
70
|
+
session = server_context&.instance_variable_get(:@notification_target)
|
|
71
|
+
envelope = server_context.respond_to?(:envelope) ? server_context.envelope : nil
|
|
72
|
+
call = Collector.begin_call(
|
|
73
|
+
"tools/list",
|
|
74
|
+
arguments: nil,
|
|
75
|
+
session_id: session_id(session),
|
|
76
|
+
server_version: server_version(server),
|
|
77
|
+
kind: "listing",
|
|
78
|
+
**client(envelope, session, server),
|
|
79
|
+
)
|
|
80
|
+
Collector.record_listing(call, tools) { |name| excluded?(server.tools[name], name) }
|
|
67
81
|
rescue StandardError
|
|
68
82
|
nil
|
|
69
83
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module McpSpan
|
|
4
|
+
# The listings already sent for each session, so the same answer to `tools/list` is sent once per session
|
|
5
|
+
# (contract, 3.11). A client lists the tools at the start of a session and often again; the count that matters is
|
|
6
|
+
# the sessions that were shown the tools, not how often they asked.
|
|
7
|
+
module Listings
|
|
8
|
+
# Sessions kept, the oldest forgotten first.
|
|
9
|
+
MAX_SESSIONS = 10_000
|
|
10
|
+
|
|
11
|
+
@sent = {}
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Whether a listing is new for its session, noting it as sent. A listing with no session always is.
|
|
16
|
+
def first?(session_id, signature)
|
|
17
|
+
return true if session_id.nil?
|
|
18
|
+
|
|
19
|
+
@lock.synchronize do
|
|
20
|
+
signatures = @sent.delete(session_id) || {}
|
|
21
|
+
new = !signatures.key?(signature)
|
|
22
|
+
signatures[signature] = true
|
|
23
|
+
@sent[session_id] = signatures
|
|
24
|
+
@sent.delete(@sent.keys.first) if @sent.size > MAX_SESSIONS
|
|
25
|
+
new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# For tests: forgets every listing sent.
|
|
30
|
+
def forget
|
|
31
|
+
@lock.synchronize { @sent.clear }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/mcpspan/text.rb
CHANGED
|
@@ -11,15 +11,6 @@ module McpSpan
|
|
|
11
11
|
# A release, a tag, a commit: the server's or the client's.
|
|
12
12
|
MAX_VERSION = 100
|
|
13
13
|
|
|
14
|
-
# First match wins, so `claude-code` is tested before `claude`.
|
|
15
|
-
CLIENT_TYPES = [
|
|
16
|
-
[["claude-code", "claude code"], "claude-code"],
|
|
17
|
-
[["claude"], "claude"],
|
|
18
|
-
[["cursor"], "cursor"],
|
|
19
|
-
[%w[chatgpt openai], "chatgpt"],
|
|
20
|
-
[["inspector"], "mcp-inspector"],
|
|
21
|
-
].freeze
|
|
22
|
-
|
|
23
14
|
module_function
|
|
24
15
|
|
|
25
16
|
# Cuts text to a limit in characters, leaving a visible sign that something was removed.
|
|
@@ -28,16 +19,8 @@ module McpSpan
|
|
|
28
19
|
text.length <= limit ? text : "#{text[0, limit - 3]}..."
|
|
29
20
|
end
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
lower = name.downcase
|
|
35
|
-
CLIENT_TYPES.each do |needles, type|
|
|
36
|
-
return type if needles.any? { |needle| lower.include?(needle) }
|
|
37
|
-
end
|
|
38
|
-
"other"
|
|
39
|
-
end
|
|
40
|
-
|
|
22
|
+
# The name as the client gave it, cut to what the API takes. The mcpspan
|
|
23
|
+
# server tells clients apart by it (contract, 7).
|
|
41
24
|
def client_name(name)
|
|
42
25
|
return nil if name.nil? || name.strip.empty?
|
|
43
26
|
|
data/lib/mcpspan/version.rb
CHANGED
data/lib/mcpspan.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "mcpspan/collector"
|
|
|
9
9
|
require_relative "mcpspan/definitions"
|
|
10
10
|
require_relative "mcpspan/argument_checks"
|
|
11
11
|
require_relative "mcpspan/repeats"
|
|
12
|
+
require_relative "mcpspan/listings"
|
|
12
13
|
require_relative "mcpspan/instrumentation"
|
|
13
14
|
require_relative "mcpspan/primitives"
|
|
14
15
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcpspan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kacper Zatoń
|
|
@@ -24,6 +24,7 @@ files:
|
|
|
24
24
|
- lib/mcpspan/definitions.rb
|
|
25
25
|
- lib/mcpspan/event.rb
|
|
26
26
|
- lib/mcpspan/instrumentation.rb
|
|
27
|
+
- lib/mcpspan/listings.rb
|
|
27
28
|
- lib/mcpspan/primitives.rb
|
|
28
29
|
- lib/mcpspan/repeats.rb
|
|
29
30
|
- lib/mcpspan/reporter.rb
|