mcpspan 0.3.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: 8d0a4e3f16e81ed07733683b6bd1984d05955ba6cd3baff32dfeae0855dd3a52
4
- data.tar.gz: 5b3a34b8f738da547c0d6cebbfdcb4debf6622eaf92f5c6bfb3ba6c68438dd5a
3
+ metadata.gz: a62e8c7de0a3fe8feed4683fa2a3aaa3b307c317f69d3f42091e62dbf32f05a7
4
+ data.tar.gz: 719d417a432bb75fdb3437d1e043c63d95754ed056d047fff42f23a28e3ac5c2
5
5
  SHA512:
6
- metadata.gz: 4b93798f1e34fec652bb47d9a184d22df8a1cf6ee64b3ef774203572b6ffe9d314e511f164bb175e6f50cee52602415aa7740c46cb74525646acfbb9b34dfa1d
7
- data.tar.gz: 8847e3f04f30ba0e3c2f2aafb693851084f963c308e3cac81c665f816f3399e591d893a0d0716e0412cfb2d77b3210902ba24dc2cc658eff8062967dbbde4863
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
@@ -141,6 +141,7 @@ module McpSpan
141
141
  response_bytes: response_bytes(response),
142
142
  # A tool the server has, refused arguments included: often the schema is why.
143
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,
144
145
  timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
145
146
  session_id: call.session_id,
146
147
  parameters: call.parameters,
@@ -48,6 +48,12 @@ module McpSpan
48
48
  nil
49
49
  end
50
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
+
51
57
  private
52
58
 
53
59
  # Sorted keys, no whitespace, minimal escaping: the same text in every SDK.
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, :definition_hash, :timestamp,
10
- :session_id, :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.
@@ -27,6 +27,7 @@ module McpSpan
27
27
  serverVersion: server_version,
28
28
  responseBytes: response_bytes,
29
29
  definitionHash: definition_hash,
30
+ repeated: repeated,
30
31
  timestamp: timestamp,
31
32
  sdkVersion: VERSION,
32
33
  sessionId: session_id,
@@ -177,6 +177,10 @@ module McpSpan
177
177
  server_version: Instrumentation.server_version(self),
178
178
  **Instrumentation.client(envelope, session, self),
179
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
180
184
  [call, tool]
181
185
  rescue *INTERNAL
182
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.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
data/lib/mcpspan.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "mcpspan/transport"
7
7
  require_relative "mcpspan/reporter"
8
8
  require_relative "mcpspan/collector"
9
9
  require_relative "mcpspan/definitions"
10
+ require_relative "mcpspan/repeats"
10
11
  require_relative "mcpspan/instrumentation"
11
12
  require_relative "mcpspan/primitives"
12
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kacper Zatoń
@@ -24,6 +24,7 @@ files:
24
24
  - lib/mcpspan/event.rb
25
25
  - lib/mcpspan/instrumentation.rb
26
26
  - lib/mcpspan/primitives.rb
27
+ - lib/mcpspan/repeats.rb
27
28
  - lib/mcpspan/reporter.rb
28
29
  - lib/mcpspan/text.rb
29
30
  - lib/mcpspan/transport.rb