mcpspan 0.6.0 → 0.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 184dd27a9089025a9a5a1e7bad3558248a4d252d1d8b2cb84553b575f35fe2c3
4
- data.tar.gz: 7d4c2bf7e4f43096ff05268dcd3ef815b92a495cbed763126aee28f3ad05b161
3
+ metadata.gz: 4267cb7cc680792a32f97df729a4743b74177b13d378f24ee6a74077e157eb58
4
+ data.tar.gz: 98751e6814b662247d7b520e45685706626c21216af97576f9e35e40ae0f0428
5
5
  SHA512:
6
- metadata.gz: a83a5bd3eacaaa255feb8d8502f9a6fb37b4d35bab3146d7c05ec9954357cdae718df0adb74898118469421fca78e8bea966e9cad4f9fbb68d5e62f738a88ea6
7
- data.tar.gz: 7649be55373354f59a4df45029b863435c4744fdfc4439b42021293546c7f3c24e5a805add9d97d8cce18d0a44f9aea0789a14101bab5a7e881104ab2719ef6e
6
+ metadata.gz: bf3842454c66ddabba4d2f27e95c8a3d74542fb32c28c1eab2c8dd6975b7c4565959f8608965f943768dd297a5fe31756cea0b8c358597399ec1ab0653a61fc5
7
+ data.tar.gz: 47d167afb98ce0155bf85c8fb93c3ea9f71abb8707c3f8f5d5b25f4039900c9f11b6682e4fabc5afa69b93f2cf8ed163405722100531d2c8fb8a44ad67b1b20d
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. For a resource or a prompt, the same, under the name it was
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
 
@@ -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)
@@ -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. Never raises.
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
- fingerprint = hash(tool)
39
+ written = fingerprint(tool)
38
40
  @lock.synchronize do
39
- @listed[tool["name"]] = fingerprint if fingerprint
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
- nil
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
- Digest::SHA256.hexdigest(canonical(hashed))[0, 16]
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
- note = lambda do |result|
56
- Definitions.note(result[:tools] || result["tools"]) if Collector.collecting? && result.is_a?(Hash)
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
- contextual = original.parameters.any? { |kind, name| %i[key keyreq].include?(kind) && name == :server_context }
60
- handlers[method] = if contextual
61
- lambda do |params, server_context: nil|
62
- note.call(original.call(params, server_context: server_context))
63
- end
64
- else
65
- ->(params) { note.call(original.call(params)) }
66
- end
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module McpSpan
4
4
  # The SDK's own version, reported with every event.
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.1"
6
6
  end
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.6.0
4
+ version: 0.8.1
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