mcpspan 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 996561d4546232205752457ea56cb9aa1570402a5f056b9578db0a6bc3a590d3
4
- data.tar.gz: 846bdb5f3f05efac26377a3a52de6c0319c05acafa7b3e5a33cc808b63973590
3
+ metadata.gz: a62e8c7de0a3fe8feed4683fa2a3aaa3b307c317f69d3f42091e62dbf32f05a7
4
+ data.tar.gz: 719d417a432bb75fdb3437d1e043c63d95754ed056d047fff42f23a28e3ac5c2
5
5
  SHA512:
6
- metadata.gz: 7295717445bbd5a6c57938769b3ebcb9d0185baf9d99f5f266387f900c7b86aa4881eadc31029232c6a754e4ca76b9419132524641b5c4d8c05ab9ddcbe6508f
7
- data.tar.gz: 7758b51df47a9aa9c6956477dac680cf083f95a756f961c15116b316e7a17a1a35237daa954d151bdaf9cb0d502a15edd17c7127ef73fa8fc9f3912c0c030bd9
6
+ metadata.gz: c595aa5e4e7416e2e0ea941ab5d032d9acf349c582731fa49a5c93859863a3210e4984aeef330c57a644ad9b8ba95c786ab21c2e7f3cfe67bb9e5938adc0f2fc
7
+ data.tar.gz: '078ec63e4be0c64dbe694a2f6fca21a9a7bda88f48ace0e9ee4a371ee1c7c9e77150d4dd52637012d62fb0cbedc7a93ba0613ee4dde7429c139f7b51039ef752'
data/README.md CHANGED
@@ -149,8 +149,10 @@ mode, not in debug.
149
149
 
150
150
  What is collected: the tool name, how long it took, whether it succeeded, the
151
151
  error type and a truncated message when it did not, how large the answer was
152
- in bytes (its size only, never its content), which client called, and the SDK
153
- version. For a resource or a prompt, the same, under the name it was
152
+ in bytes (its size only, never its content), whether it repeated the previous
153
+ call's arguments to the same tool in its session (compared in your process;
154
+ the arguments, or any digest of them, never leave it), which client called,
155
+ and the SDK version. For a resource or a prompt, the same, under the name it was
154
156
  registered with: never the address a client read, only its template or, for
155
157
  an address the server does not have, its scheme.
156
158
 
@@ -20,7 +20,7 @@ module McpSpan
20
20
 
21
21
  # What an integration knows about one call as it starts. A kind of nil is a tool call.
22
22
  Call = Struct.new(:tool_name, :parameters, :client_name, :client_version, :server_version, :session_id, :started,
23
- :timestamp, :kind, keyword_init: true,)
23
+ :timestamp, :kind, :repeated, keyword_init: true,)
24
24
 
25
25
  @lock = Mutex.new
26
26
  @reporter = nil
@@ -139,6 +139,9 @@ module McpSpan
139
139
  client_version: Text.version(call.client_version),
140
140
  server_version: Text.version(call.server_version),
141
141
  response_bytes: response_bytes(response),
142
+ # A tool the server has, refused arguments included: often the schema is why.
143
+ definition_hash: call.kind.nil? && source != Source::UNKNOWN_TOOL ? Definitions.of(call.tool_name) : nil,
144
+ repeated: call.kind.nil? && call.repeated ? true : nil,
142
145
  timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
143
146
  session_id: call.session_id,
144
147
  parameters: call.parameters,
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module McpSpan
7
+ # Tool definitions as the server lists them, fingerprinted (contract, 3.8). Rewording a description can change how
8
+ # agents use a tool more than a change to its code; the fingerprint is taken from the answer to `tools/list`, what
9
+ # an agent actually read, and sent with every call to the tool. Kept for the process: one process reports to one
10
+ # server.
11
+ module Definitions
12
+ HASHED = %w[name title description inputSchema].freeze
13
+ ESCAPES = { '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r",
14
+ "\t" => "\\t", }.freeze
15
+
16
+ @listed = {}
17
+ @lock = Mutex.new
18
+
19
+ class << self
20
+ # The latest fingerprint listed for a tool, or nil when no listing in this process named it.
21
+ def of(tool_name)
22
+ @lock.synchronize { @listed[tool_name] }
23
+ end
24
+
25
+ # Notes every tool in a listing, in whatever key style the gem keeps them. Never raises.
26
+ def note(tools)
27
+ JSON.parse(JSON.generate(Array(tools))).each do |tool|
28
+ next unless tool.is_a?(Hash) && tool["name"].is_a?(String)
29
+
30
+ fingerprint = hash(tool)
31
+ @lock.synchronize { @listed[tool["name"]] = fingerprint } if fingerprint
32
+ end
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ # For tests: forgets every listing.
38
+ def forget
39
+ @lock.synchronize { @listed.clear }
40
+ end
41
+
42
+ # The first 16 hex characters of the SHA-256 of the tool's name, title, description and input schema, as
43
+ # canonical JSON; nil for a definition that cannot be written so.
44
+ def hash(tool)
45
+ hashed = HASHED.each_with_object({}) { |field, out| out[field] = tool[field] unless tool[field].nil? }
46
+ Digest::SHA256.hexdigest(canonical(hashed))[0, 16]
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ # A value as canonical JSON (contract, 3.8), read the way it was sent: through JSON, so symbols and strings
52
+ # alike. Raises on what cannot be written so.
53
+ def canonical_text(value)
54
+ canonical(JSON.parse(JSON.generate(value)))
55
+ end
56
+
57
+ private
58
+
59
+ # Sorted keys, no whitespace, minimal escaping: the same text in every SDK.
60
+ def canonical(value)
61
+ case value
62
+ when nil then "null"
63
+ when true then "true"
64
+ when false then "false"
65
+ when Integer then value.to_s
66
+ when Float
67
+ raise ArgumentError, "not a JSON number" unless value.finite?
68
+
69
+ value == value.floor && value.abs < 1e15 ? value.to_i.to_s : value.to_s
70
+ when String then text(value)
71
+ when Array then "[#{value.map { |item| canonical(item) }.join(",")}]"
72
+ when Hash
73
+ pairs = value.map { |key, item| [key.to_s, item] }.sort_by(&:first)
74
+ "{#{pairs.map { |key, item| "#{text(key)}:#{canonical(item)}" }.join(",")}}"
75
+ else raise ArgumentError, "cannot fingerprint #{value.class}"
76
+ end
77
+ end
78
+
79
+ def text(value)
80
+ out = +'"'
81
+ value.each_char do |character|
82
+ out << (ESCAPES[character] || (character.ord < 0x20 ? format("\\u%04x", character.ord) : character))
83
+ end
84
+ out << '"'
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/mcpspan/event.rb CHANGED
@@ -6,8 +6,8 @@ module McpSpan
6
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
7
  Event = Struct.new(
8
8
  :id, :kind, :tool_name, :duration_ms, :success, :error_source, :error_type, :error_message,
9
- :client_type, :client_name, :client_version, :server_version, :response_bytes, :timestamp, :session_id,
10
- :parameters,
9
+ :client_type, :client_name, :client_version, :server_version, :response_bytes, :definition_hash, :repeated,
10
+ :timestamp, :session_id, :parameters,
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.
@@ -26,6 +26,8 @@ module McpSpan
26
26
  clientVersion: client_version,
27
27
  serverVersion: server_version,
28
28
  responseBytes: response_bytes,
29
+ definitionHash: definition_hash,
30
+ repeated: repeated,
29
31
  timestamp: timestamp,
30
32
  sdkVersion: VERSION,
31
33
  sessionId: session_id,
@@ -39,9 +39,35 @@ module McpSpan
39
39
 
40
40
  server.singleton_class.prepend(ServerHooks)
41
41
  server.singleton_class.prepend(Primitives::ServerHooks) if Primitives.hookable?
42
+ watch_listings(server)
42
43
  server
43
44
  end
44
45
 
46
+ # Notes the tools each `tools/list` answer describes (contract, 3.8). The gem binds its handlers when the server
47
+ # is built, so the bound handler is wrapped where the server keeps it, taking the server's context only if the
48
+ # handler it wraps asks for it, as the gem decides from the handler's own parameters.
49
+ def watch_listings(server)
50
+ handlers = server.instance_variable_get(:@handlers)
51
+ method = ::MCP::Methods::TOOLS_LIST
52
+ original = handlers.is_a?(Hash) ? handlers[method] : nil
53
+ return unless original.respond_to?(:call) && original.respond_to?(:parameters)
54
+
55
+ note = lambda do |result|
56
+ Definitions.note(result[:tools] || result["tools"]) if Collector.collecting? && result.is_a?(Hash)
57
+ result
58
+ 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
67
+ rescue StandardError
68
+ nil
69
+ end
70
+
45
71
  def hookable?
46
72
  HOOKED.all? { |name| ::MCP::Server.private_method_defined?(name) }
47
73
  end
@@ -151,6 +177,10 @@ module McpSpan
151
177
  server_version: Instrumentation.server_version(self),
152
178
  **Instrumentation.client(envelope, session, self),
153
179
  )
180
+ # Compared once, as the request arrives, before any validation (contract, 3.9).
181
+ if call&.session_id && !Repeats.continues_earlier_call?(request)
182
+ call.repeated = Repeats.note(call.session_id, name, request[:arguments])
183
+ end
154
184
  [call, tool]
155
185
  rescue *INTERNAL
156
186
  nil
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module McpSpan
6
+ # Whether a call repeats the previous call to the same tool in the same session (contract, 3.9): an agent stuck in a
7
+ # loop. Only the answer leaves the process. Kept here is a SHA-256 of the canonical arguments of the latest call per
8
+ # session and tool, never sent: a digest of a short identifier or an enumerated value is found by trying every one.
9
+ module Repeats
10
+ # Session and tool pairs kept, the oldest forgotten first.
11
+ MAX_KEPT = 10_000
12
+
13
+ # Insertion-ordered: a pair noted again is moved to the end, so the first is always the longest unused.
14
+ @latest = {}
15
+ @lock = Mutex.new
16
+
17
+ class << self
18
+ # Notes a call's arguments, as the client sent them, and says whether they are the previous call's to the same
19
+ # tool in the same session. Arguments that cannot be written down are never a repeat.
20
+ def note(session_id, tool_name, arguments)
21
+ digest = Digest::SHA256.digest(Definitions.canonical_text(arguments || {}))
22
+ key = [session_id, tool_name.to_s]
23
+ @lock.synchronize do
24
+ previous = @latest.delete(key)
25
+ @latest[key] = digest
26
+ @latest.shift if @latest.size > MAX_KEPT
27
+ previous == digest
28
+ end
29
+ rescue StandardError
30
+ false
31
+ end
32
+
33
+ # Whether a call answers an interim result's question (2026-07-28): it then continues that call, and is neither
34
+ # compared nor kept.
35
+ def continues_earlier_call?(request)
36
+ return false unless request.is_a?(Hash)
37
+
38
+ %i[inputResponses requestState].any? do |name|
39
+ value = request[name] || request[name.to_s]
40
+ !value.nil? && value != ""
41
+ end
42
+ end
43
+
44
+ # For tests: forgets every call.
45
+ def forget
46
+ @lock.synchronize { @latest.clear }
47
+ end
48
+ end
49
+ end
50
+ 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.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
data/lib/mcpspan.rb CHANGED
@@ -6,6 +6,8 @@ require_relative "mcpspan/event"
6
6
  require_relative "mcpspan/transport"
7
7
  require_relative "mcpspan/reporter"
8
8
  require_relative "mcpspan/collector"
9
+ require_relative "mcpspan/definitions"
10
+ require_relative "mcpspan/repeats"
9
11
  require_relative "mcpspan/instrumentation"
10
12
  require_relative "mcpspan/primitives"
11
13
 
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kacper Zatoń
@@ -20,9 +20,11 @@ files:
20
20
  - README.md
21
21
  - lib/mcpspan.rb
22
22
  - lib/mcpspan/collector.rb
23
+ - lib/mcpspan/definitions.rb
23
24
  - lib/mcpspan/event.rb
24
25
  - lib/mcpspan/instrumentation.rb
25
26
  - lib/mcpspan/primitives.rb
27
+ - lib/mcpspan/repeats.rb
26
28
  - lib/mcpspan/reporter.rb
27
29
  - lib/mcpspan/text.rb
28
30
  - lib/mcpspan/transport.rb