mcpspan 0.6.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 +34 -0
- 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/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
|
@@ -159,6 +159,40 @@ module McpSpan
|
|
|
159
159
|
nil
|
|
160
160
|
end
|
|
161
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
|
+
|
|
162
196
|
# Which declared arguments a refusal was over (contract, 3.10), or nil when none were found.
|
|
163
197
|
def refused_names(call)
|
|
164
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/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
|