mcpspan 0.3.0 → 0.5.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: 809dac003b7d4f882b23c28d717c060123f019b3dd833a9df61bc868ba7c6445
4
+ data.tar.gz: 03b8898e5e333f995e9f10b9512bf35d658356e6264eeffd62a723bbdaa55aec
5
5
  SHA512:
6
- metadata.gz: 4b93798f1e34fec652bb47d9a184d22df8a1cf6ee64b3ef774203572b6ffe9d314e511f164bb175e6f50cee52602415aa7740c46cb74525646acfbb9b34dfa1d
7
- data.tar.gz: 8847e3f04f30ba0e3c2f2aafb693851084f963c308e3cac81c665f816f3399e591d893a0d0716e0412cfb2d77b3210902ba24dc2cc658eff8062967dbbde4863
6
+ metadata.gz: 98b5fa6edb27bddcb8d9c06ba77a6f4e5715ef0923339a15a49bc329dbc521826887cc34b9474b0c3b2efa2bffdba7e334cb015e51d57956cc2acab919a91d84
7
+ data.tar.gz: ec137d06a3c7b5b08f3e55fe6ae930cc35cb0860a07e4fcfbde699e807e52b529ddd476e40b9a93f82ce82a7800ef78372b28868e95cfe87e770efebab4d6a90
data/README.md CHANGED
@@ -142,6 +142,14 @@ refused call carries no message, because validation text can quote back what
142
142
  the agent sent. Whether a call reached your tool is observed directly, not
143
143
  read from the server's wording.
144
144
 
145
+ For a refusal of arguments, the SDK also says which ones. It checks what the
146
+ agent sent against the input schema your server listed, in your process, and
147
+ records the top-level arguments that did not match, by the names the schema
148
+ declares, so the tool's page can show that 29 refusals were all `passengers`.
149
+ Values are never sent, and neither is a name the agent made up. It works from
150
+ the latest `tools/list` your server answered in the same process; a refusal
151
+ before any listing names nothing.
152
+
145
153
  ## Privacy
146
154
 
147
155
  **Parameter values never leave your process.** Not by default, not in any
@@ -149,8 +157,14 @@ mode, not in debug.
149
157
 
150
158
  What is collected: the tool name, how long it took, whether it succeeded, the
151
159
  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
160
+ in bytes (its size only, never its content), whether it repeated the previous
161
+ call's arguments to the same tool in its session (compared in your process;
162
+ the arguments, or any digest of them, never leave it), a fingerprint of the
163
+ tool's definition as your server lists it (its name, title, description and
164
+ input schema, hashed, so the dashboard can mark when you changed it), for a call your
165
+ server refused for its arguments the names of those that did not match the
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
154
168
  registered with: never the address a client read, only its template or, for
155
169
  an address the server does not have, its scheme.
156
170
 
@@ -166,6 +180,18 @@ called with `destination` and never with `departure_date` tells you your tool
166
180
  description is not landing. Knowing which destination tells you nothing you
167
181
  needed, and puts your users' data somewhere it does not belong.
168
182
 
183
+ Error messages are sent, cut short, because they are usually what says why a
184
+ call failed. If your tools can fail with text you would not send anywhere, as
185
+ a tool that runs commands or reads files might quote a path or a token, turn
186
+ them off:
187
+
188
+ ```ruby
189
+ McpSpan.instrument(server, capture_error_messages: false)
190
+ ```
191
+
192
+ Every failure is still recorded, with where it came from and the exception's
193
+ class. Only the text is left out.
194
+
169
195
  ## Self-hosting
170
196
 
171
197
  ```ruby
@@ -207,6 +233,7 @@ Keywords of `McpSpan.instrument` and `McpSpan.configure`.
207
233
  | `api_key` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
208
234
  | `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
209
235
  | `capture_parameter_names` | `false` | Records parameter names and types, never values. |
236
+ | `capture_error_messages` | `true` | Sends the text of a failure, cut short. Off sends that it failed and how, without the text. |
210
237
  | `server_version` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
211
238
  | `debug` | `false` | Writes delivery diagnostics to standard error. |
212
239
  | `on_diagnostic` | - | Receives diagnostics instead. Implies `debug`. |
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module McpSpan
6
+ # Which top-level arguments of a refused call did not match the tool's input schema (contract, 3.10). The server's
7
+ # own refusal is not read: each validation library words it differently, and some quote the value the agent sent.
8
+ # The arguments are checked here instead, against the schema the server listed, by a small set of rules that never
9
+ # fail what they do not understand. Only names the schema declares come out, so nothing the client made up, and no
10
+ # value, is sent.
11
+ module ArgumentChecks
12
+ # Names sent at most, per call.
13
+ MAX_NAMES = 20
14
+
15
+ class << self
16
+ # The declared names whose arguments fail the schema, sorted, at most twenty. Both are read the way they were
17
+ # sent, through JSON, so symbols and strings alike. Never raises.
18
+ def invalid(schema, arguments)
19
+ rules = plain(schema)
20
+ return [] unless rules.is_a?(Hash)
21
+
22
+ values = arguments.nil? ? {} : plain(arguments)
23
+ return [] unless values.is_a?(Hash)
24
+
25
+ names = []
26
+ required = rules["required"]
27
+ names.concat(required.select { |name| name.is_a?(String) && !values.key?(name) }) if required.is_a?(Array)
28
+ properties = rules["properties"]
29
+ if properties.is_a?(Hash)
30
+ properties.each do |name, rule|
31
+ names << name if values.key?(name) && !matches?(rule, values[name])
32
+ end
33
+ end
34
+ names.uniq.sort.first(MAX_NAMES)
35
+ rescue StandardError
36
+ []
37
+ end
38
+
39
+ private
40
+
41
+ def plain(value)
42
+ JSON.parse(JSON.generate(value))
43
+ end
44
+
45
+ # Whether a value passes a schema under the checks the contract lists, and only those.
46
+ def matches?(schema, value)
47
+ return false if schema == false
48
+ return true unless schema.is_a?(Hash)
49
+
50
+ type = schema["type"]
51
+ return false if type.is_a?(String) && !type?(type, value)
52
+ return false if type.is_a?(Array) && type.all?(String) && type.none? { |name| type?(name, value) }
53
+
54
+ allowed = schema["enum"]
55
+ if allowed.is_a?(Array)
56
+ sent = Definitions.canonical_text(value)
57
+ return false if allowed.none? { |option| Definitions.canonical_text(option) == sent }
58
+ end
59
+ if schema.key?("const") && Definitions.canonical_text(schema["const"]) != Definitions.canonical_text(value)
60
+ return false
61
+ end
62
+
63
+ within?(schema, value)
64
+ end
65
+
66
+ # The bounds on numbers, lengths and counts, and what objects and arrays hold.
67
+ def within?(schema, value)
68
+ bound = ->(name) { number?(schema[name]) ? schema[name] : nil }
69
+ if number?(value)
70
+ return false if bound.call("minimum")&.then { |minimum| value < minimum }
71
+ return false if bound.call("maximum")&.then { |maximum| value > maximum }
72
+ return false if bound.call("exclusiveMinimum")&.then { |above| value <= above }
73
+ return false if bound.call("exclusiveMaximum")&.then { |below| value >= below }
74
+ end
75
+
76
+ case value
77
+ when String
78
+ # A Ruby string's length is in characters, which in UTF-8 are code points.
79
+ return false if bound.call("minLength")&.then { |shortest| value.length < shortest }
80
+ return false if bound.call("maxLength")&.then { |longest| value.length > longest }
81
+ when Array
82
+ return false if bound.call("minItems")&.then { |fewest| value.length < fewest }
83
+ return false if bound.call("maxItems")&.then { |most| value.length > most }
84
+
85
+ each = schema["items"]
86
+ checked = each.is_a?(Hash) || [true, false].include?(each)
87
+ return false if checked && !value.all? { |item| matches?(each, item) }
88
+ when Hash
89
+ required = schema["required"]
90
+ return false if required.is_a?(Array) && required.any? { |name| name.is_a?(String) && !value.key?(name) }
91
+
92
+ properties = schema["properties"]
93
+ if properties.is_a?(Hash) && properties.any? { |name, rule| value.key?(name) && !matches?(rule, value[name]) }
94
+ return false
95
+ end
96
+ end
97
+ true
98
+ end
99
+
100
+ def type?(type, value)
101
+ case type
102
+ when "string" then value.is_a?(String)
103
+ when "number" then number?(value)
104
+ when "integer" then number?(value) && value == value.truncate
105
+ when "boolean" then [true, false].include?(value)
106
+ when "object" then value.is_a?(Hash)
107
+ when "array" then value.is_a?(Array)
108
+ when "null" then value.nil?
109
+ # A type this list does not know is not checked.
110
+ else true
111
+ end
112
+ end
113
+
114
+ def number?(value)
115
+ value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -14,13 +14,14 @@ module McpSpan
14
14
  MAX_EVENTS_PER_REQUEST = 1_000
15
15
 
16
16
  SETTINGS = %i[
17
- api_key endpoint capture_parameter_names server_version debug on_diagnostic flush_on_exit flush_interval
18
- max_batch_size max_queue_size
17
+ api_key endpoint capture_parameter_names capture_error_messages server_version debug on_diagnostic
18
+ flush_on_exit flush_interval max_batch_size max_queue_size
19
19
  ].freeze
20
20
 
21
21
  # What an integration knows about one call as it starts. A kind of nil is a tool call.
22
+ # `arguments` are kept as sent, by reference, only to tell which a refusal was over (contract, 3.10).
22
23
  Call = Struct.new(:tool_name, :parameters, :client_name, :client_version, :server_version, :session_id, :started,
23
- :timestamp, :kind, keyword_init: true,)
24
+ :timestamp, :kind, :repeated, :arguments, keyword_init: true,)
24
25
 
25
26
  @lock = Mutex.new
26
27
  @reporter = nil
@@ -101,6 +102,7 @@ module McpSpan
101
102
  started: started,
102
103
  timestamp: timestamp,
103
104
  kind: kind,
105
+ arguments: arguments,
104
106
  )
105
107
  end
106
108
 
@@ -123,8 +125,11 @@ module McpSpan
123
125
  def record(call, success:, source: nil, type: nil, message: nil, response: nil)
124
126
  duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - call.started) * 1000.0
125
127
  reporter = @reporter
126
- return if reporter.nil?
128
+ settings = @settings
129
+ return if reporter.nil? || settings.nil?
127
130
 
131
+ # The text of a failure, unless the developer chose to send none (contract, 5).
132
+ message = nil unless settings[:capture_error_messages]
128
133
  reporter.record(Event.new(
129
134
  id: SecureRandom.uuid,
130
135
  kind: call.kind,
@@ -141,6 +146,8 @@ module McpSpan
141
146
  response_bytes: response_bytes(response),
142
147
  # A tool the server has, refused arguments included: often the schema is why.
143
148
  definition_hash: call.kind.nil? && source != Source::UNKNOWN_TOOL ? Definitions.of(call.tool_name) : nil,
149
+ repeated: call.kind.nil? && call.repeated ? true : nil,
150
+ invalid_arguments: call.kind.nil? && source == Source::ARGUMENTS ? refused_names(call) : nil,
144
151
  timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
145
152
  session_id: call.session_id,
146
153
  parameters: call.parameters,
@@ -149,6 +156,12 @@ module McpSpan
149
156
  nil
150
157
  end
151
158
 
159
+ # Which declared arguments a refusal was over (contract, 3.10), or nil when none were found.
160
+ def refused_names(call)
161
+ names = ArgumentChecks.invalid(Definitions.schema_of(call.tool_name), call.arguments)
162
+ names.empty? ? nil : names.map { |name| Text.truncate(name, Text::MAX_NAME) }
163
+ end
164
+
152
165
  # For tests: forgets that the missing endpoint was already mentioned.
153
166
  def forget_no_endpoint_notice
154
167
  @lock.synchronize { @said_no_endpoint = false }
@@ -211,6 +224,7 @@ module McpSpan
211
224
  api_key: first_set(settings[:api_key], ENV.fetch("MCPSPAN_API_KEY", nil)),
212
225
  endpoint: first_set(settings[:endpoint], ENV.fetch("MCPSPAN_ENDPOINT", nil)),
213
226
  capture_parameter_names: settings[:capture_parameter_names] ? true : false,
227
+ capture_error_messages: settings.fetch(:capture_error_messages, true) ? true : false,
214
228
  server_version: first_set(settings[:server_version], ENV.fetch("MCPSPAN_SERVER_VERSION", nil)),
215
229
  debug: debug,
216
230
  on_diagnostic: on_diagnostic,
@@ -14,6 +14,8 @@ module McpSpan
14
14
  "\t" => "\\t", }.freeze
15
15
 
16
16
  @listed = {}
17
+ # Each tool's input schema as last listed, to tell which arguments a refusal was over (contract, 3.10).
18
+ @schemas = {}
17
19
  @lock = Mutex.new
18
20
 
19
21
  class << self
@@ -22,13 +24,21 @@ module McpSpan
22
24
  @lock.synchronize { @listed[tool_name] }
23
25
  end
24
26
 
27
+ # The latest input schema listed for a tool, or nil when no listing in this process named it.
28
+ def schema_of(tool_name)
29
+ @lock.synchronize { @schemas[tool_name] }
30
+ end
31
+
25
32
  # Notes every tool in a listing, in whatever key style the gem keeps them. Never raises.
26
33
  def note(tools)
27
34
  JSON.parse(JSON.generate(Array(tools))).each do |tool|
28
35
  next unless tool.is_a?(Hash) && tool["name"].is_a?(String)
29
36
 
30
37
  fingerprint = hash(tool)
31
- @lock.synchronize { @listed[tool["name"]] = fingerprint } if fingerprint
38
+ @lock.synchronize do
39
+ @listed[tool["name"]] = fingerprint if fingerprint
40
+ @schemas[tool["name"]] = tool["inputSchema"]
41
+ end
32
42
  end
33
43
  rescue StandardError
34
44
  nil
@@ -36,7 +46,10 @@ module McpSpan
36
46
 
37
47
  # For tests: forgets every listing.
38
48
  def forget
39
- @lock.synchronize { @listed.clear }
49
+ @lock.synchronize do
50
+ @listed.clear
51
+ @schemas.clear
52
+ end
40
53
  end
41
54
 
42
55
  # The first 16 hex characters of the SHA-256 of the tool's name, title, description and input schema, as
@@ -48,6 +61,12 @@ module McpSpan
48
61
  nil
49
62
  end
50
63
 
64
+ # A value as canonical JSON (contract, 3.8), read the way it was sent: through JSON, so symbols and strings
65
+ # alike. Raises on what cannot be written so.
66
+ def canonical_text(value)
67
+ canonical(JSON.parse(JSON.generate(value)))
68
+ end
69
+
51
70
  private
52
71
 
53
72
  # 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
+ :invalid_arguments, :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,8 @@ module McpSpan
27
27
  serverVersion: server_version,
28
28
  responseBytes: response_bytes,
29
29
  definitionHash: definition_hash,
30
+ repeated: repeated,
31
+ invalidArguments: invalid_arguments,
30
32
  timestamp: timestamp,
31
33
  sdkVersion: VERSION,
32
34
  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.5.0"
6
6
  end
data/lib/mcpspan.rb CHANGED
@@ -7,6 +7,8 @@ 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/argument_checks"
11
+ require_relative "mcpspan/repeats"
10
12
  require_relative "mcpspan/instrumentation"
11
13
  require_relative "mcpspan/primitives"
12
14
 
@@ -18,8 +20,9 @@ require_relative "mcpspan/primitives"
18
20
  # Without an API key nothing is collected and nothing is sent. Parameter values never leave the process.
19
21
  #
20
22
  # Settings: +api_key+ (else +MCPSPAN_API_KEY+), +endpoint+, your mcpspan installation (else +MCPSPAN_ENDPOINT+; no
21
- # default, and nothing is collected without it), +capture_parameter_names+, +debug+, +on_diagnostic+,
22
- # +flush_on_exit+, +flush_interval+ (seconds), +max_batch_size+, +max_queue_size+. Nothing here raises over a setting.
23
+ # default, and nothing is collected without it), +capture_parameter_names+, +capture_error_messages+ (on unless
24
+ # false), +debug+, +on_diagnostic+, +flush_on_exit+, +flush_interval+ (seconds), +max_batch_size+, +max_queue_size+.
25
+ # Nothing here raises over a setting.
23
26
  module McpSpan
24
27
  @excluded_names = Set.new
25
28
 
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kacper Zatoń
@@ -19,11 +19,13 @@ files:
19
19
  - LICENSE
20
20
  - README.md
21
21
  - lib/mcpspan.rb
22
+ - lib/mcpspan/argument_checks.rb
22
23
  - lib/mcpspan/collector.rb
23
24
  - lib/mcpspan/definitions.rb
24
25
  - lib/mcpspan/event.rb
25
26
  - lib/mcpspan/instrumentation.rb
26
27
  - lib/mcpspan/primitives.rb
28
+ - lib/mcpspan/repeats.rb
27
29
  - lib/mcpspan/reporter.rb
28
30
  - lib/mcpspan/text.rb
29
31
  - lib/mcpspan/transport.rb