mcpspan 0.4.0 → 0.6.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: a62e8c7de0a3fe8feed4683fa2a3aaa3b307c317f69d3f42091e62dbf32f05a7
4
- data.tar.gz: 719d417a432bb75fdb3437d1e043c63d95754ed056d047fff42f23a28e3ac5c2
3
+ metadata.gz: 184dd27a9089025a9a5a1e7bad3558248a4d252d1d8b2cb84553b575f35fe2c3
4
+ data.tar.gz: 7d4c2bf7e4f43096ff05268dcd3ef815b92a495cbed763126aee28f3ad05b161
5
5
  SHA512:
6
- metadata.gz: c595aa5e4e7416e2e0ea941ab5d032d9acf349c582731fa49a5c93859863a3210e4984aeef330c57a644ad9b8ba95c786ab21c2e7f3cfe67bb9e5938adc0f2fc
7
- data.tar.gz: '078ec63e4be0c64dbe694a2f6fca21a9a7bda88f48ace0e9ee4a371ee1c7c9e77150d4dd52637012d62fb0cbedc7a93ba0613ee4dde7429c139f7b51039ef752'
6
+ metadata.gz: a83a5bd3eacaaa255feb8d8502f9a6fb37b4d35bab3146d7c05ec9954357cdae718df0adb74898118469421fca78e8bea966e9cad4f9fbb68d5e62f738a88ea6
7
+ data.tar.gz: 7649be55373354f59a4df45029b863435c4744fdfc4439b42021293546c7f3c24e5a805add9d97d8cce18d0a44f9aea0789a14101bab5a7e881104ab2719ef6e
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
@@ -151,8 +159,12 @@ 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
160
  in bytes (its size only, never its content), whether it repeated the previous
153
161
  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
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
156
168
  registered with: never the address a client read, only its template or, for
157
169
  an address the server does not have, its scheme.
158
170
 
@@ -168,6 +180,18 @@ called with `destination` and never with `departure_date` tells you your tool
168
180
  description is not landing. Knowing which destination tells you nothing you
169
181
  needed, and puts your users' data somewhere it does not belong.
170
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
+
171
195
  ## Self-hosting
172
196
 
173
197
  ```ruby
@@ -209,6 +233,7 @@ Keywords of `McpSpan.instrument` and `McpSpan.configure`.
209
233
  | `api_key` | `MCPSPAN_API_KEY` | Identifies your server. Without it, nothing is collected. |
210
234
  | `endpoint` | `MCPSPAN_ENDPOINT`; none | Your mcpspan installation. Nothing is collected without it. |
211
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. |
212
237
  | `server_version` | `MCPSPAN_SERVER_VERSION`, then the server's own | The version to record calls under: a release, a tag, a commit. |
213
238
  | `debug` | `false` | Writes delivery diagnostics to standard error. |
214
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, :repeated, 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,
@@ -134,7 +139,10 @@ module McpSpan
134
139
  error_source: source,
135
140
  error_type: type && Text.truncate(type, Text::MAX_NAME),
136
141
  error_message: message.nil? || message.empty? ? nil : message,
137
- client_type: Text.client_type(call.client_name),
142
+ # Always "unknown": the mcpspan server decides the client from its name
143
+ # (contract, 7). Sent only because servers released before 2026-10-15
144
+ # refuse an event without it.
145
+ client_type: "unknown",
138
146
  client_name: Text.client_name(call.client_name),
139
147
  client_version: Text.version(call.client_version),
140
148
  server_version: Text.version(call.server_version),
@@ -142,6 +150,7 @@ module McpSpan
142
150
  # A tool the server has, refused arguments included: often the schema is why.
143
151
  definition_hash: call.kind.nil? && source != Source::UNKNOWN_TOOL ? Definitions.of(call.tool_name) : nil,
144
152
  repeated: call.kind.nil? && call.repeated ? true : nil,
153
+ invalid_arguments: call.kind.nil? && source == Source::ARGUMENTS ? refused_names(call) : nil,
145
154
  timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
146
155
  session_id: call.session_id,
147
156
  parameters: call.parameters,
@@ -150,6 +159,12 @@ module McpSpan
150
159
  nil
151
160
  end
152
161
 
162
+ # Which declared arguments a refusal was over (contract, 3.10), or nil when none were found.
163
+ def refused_names(call)
164
+ names = ArgumentChecks.invalid(Definitions.schema_of(call.tool_name), call.arguments)
165
+ names.empty? ? nil : names.map { |name| Text.truncate(name, Text::MAX_NAME) }
166
+ end
167
+
153
168
  # For tests: forgets that the missing endpoint was already mentioned.
154
169
  def forget_no_endpoint_notice
155
170
  @lock.synchronize { @said_no_endpoint = false }
@@ -212,6 +227,7 @@ module McpSpan
212
227
  api_key: first_set(settings[:api_key], ENV.fetch("MCPSPAN_API_KEY", nil)),
213
228
  endpoint: first_set(settings[:endpoint], ENV.fetch("MCPSPAN_ENDPOINT", nil)),
214
229
  capture_parameter_names: settings[:capture_parameter_names] ? true : false,
230
+ capture_error_messages: settings.fetch(:capture_error_messages, true) ? true : false,
215
231
  server_version: first_set(settings[:server_version], ENV.fetch("MCPSPAN_SERVER_VERSION", nil)),
216
232
  debug: debug,
217
233
  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
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
- :timestamp, :session_id, :parameters,
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.
@@ -28,6 +28,7 @@ module McpSpan
28
28
  responseBytes: response_bytes,
29
29
  definitionHash: definition_hash,
30
30
  repeated: repeated,
31
+ invalidArguments: invalid_arguments,
31
32
  timestamp: timestamp,
32
33
  sdkVersion: VERSION,
33
34
  sessionId: session_id,
data/lib/mcpspan/text.rb CHANGED
@@ -11,15 +11,6 @@ module McpSpan
11
11
  # A release, a tag, a commit: the server's or the client's.
12
12
  MAX_VERSION = 100
13
13
 
14
- # First match wins, so `claude-code` is tested before `claude`.
15
- CLIENT_TYPES = [
16
- [["claude-code", "claude code"], "claude-code"],
17
- [["claude"], "claude"],
18
- [["cursor"], "cursor"],
19
- [%w[chatgpt openai], "chatgpt"],
20
- [["inspector"], "mcp-inspector"],
21
- ].freeze
22
-
23
14
  module_function
24
15
 
25
16
  # Cuts text to a limit in characters, leaving a visible sign that something was removed.
@@ -28,16 +19,8 @@ module McpSpan
28
19
  text.length <= limit ? text : "#{text[0, limit - 3]}..."
29
20
  end
30
21
 
31
- def client_type(name)
32
- return "unknown" if name.nil? || name.strip.empty?
33
-
34
- lower = name.downcase
35
- CLIENT_TYPES.each do |needles, type|
36
- return type if needles.any? { |needle| lower.include?(needle) }
37
- end
38
- "other"
39
- end
40
-
22
+ # The name as the client gave it, cut to what the API takes. The mcpspan
23
+ # server tells clients apart by it (contract, 7).
41
24
  def client_name(name)
42
25
  return nil if name.nil? || name.strip.empty?
43
26
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module McpSpan
4
4
  # The SDK's own version, reported with every event.
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.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/argument_checks"
10
11
  require_relative "mcpspan/repeats"
11
12
  require_relative "mcpspan/instrumentation"
12
13
  require_relative "mcpspan/primitives"
@@ -19,8 +20,9 @@ require_relative "mcpspan/primitives"
19
20
  # Without an API key nothing is collected and nothing is sent. Parameter values never leave the process.
20
21
  #
21
22
  # Settings: +api_key+ (else +MCPSPAN_API_KEY+), +endpoint+, your mcpspan installation (else +MCPSPAN_ENDPOINT+; no
22
- # default, and nothing is collected without it), +capture_parameter_names+, +debug+, +on_diagnostic+,
23
- # +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.
24
26
  module McpSpan
25
27
  @excluded_names = Set.new
26
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.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kacper Zatoń
@@ -19,6 +19,7 @@ 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