mcpspan 0.4.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 +4 -4
- data/README.md +27 -2
- data/lib/mcpspan/argument_checks.rb +119 -0
- data/lib/mcpspan/collector.rb +17 -4
- data/lib/mcpspan/definitions.rb +15 -2
- data/lib/mcpspan/event.rb +2 -1
- data/lib/mcpspan/version.rb +1 -1
- data/lib/mcpspan.rb +4 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 809dac003b7d4f882b23c28d717c060123f019b3dd833a9df61bc868ba7c6445
|
|
4
|
+
data.tar.gz: 03b8898e5e333f995e9f10b9512bf35d658356e6264eeffd62a723bbdaa55aec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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),
|
|
155
|
-
|
|
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
|
data/lib/mcpspan/collector.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
@@ -142,6 +147,7 @@ module McpSpan
|
|
|
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,
|
|
144
149
|
repeated: call.kind.nil? && call.repeated ? true : nil,
|
|
150
|
+
invalid_arguments: call.kind.nil? && source == Source::ARGUMENTS ? refused_names(call) : nil,
|
|
145
151
|
timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
|
|
146
152
|
session_id: call.session_id,
|
|
147
153
|
parameters: call.parameters,
|
|
@@ -150,6 +156,12 @@ module McpSpan
|
|
|
150
156
|
nil
|
|
151
157
|
end
|
|
152
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
|
+
|
|
153
165
|
# For tests: forgets that the missing endpoint was already mentioned.
|
|
154
166
|
def forget_no_endpoint_notice
|
|
155
167
|
@lock.synchronize { @said_no_endpoint = false }
|
|
@@ -212,6 +224,7 @@ module McpSpan
|
|
|
212
224
|
api_key: first_set(settings[:api_key], ENV.fetch("MCPSPAN_API_KEY", nil)),
|
|
213
225
|
endpoint: first_set(settings[:endpoint], ENV.fetch("MCPSPAN_ENDPOINT", nil)),
|
|
214
226
|
capture_parameter_names: settings[:capture_parameter_names] ? true : false,
|
|
227
|
+
capture_error_messages: settings.fetch(:capture_error_messages, true) ? true : false,
|
|
215
228
|
server_version: first_set(settings[:server_version], ENV.fetch("MCPSPAN_SERVER_VERSION", nil)),
|
|
216
229
|
debug: debug,
|
|
217
230
|
on_diagnostic: on_diagnostic,
|
data/lib/mcpspan/definitions.rb
CHANGED
|
@@ -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
|
|
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
|
|
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/version.rb
CHANGED
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+, +
|
|
23
|
-
# +flush_on_exit+, +flush_interval+ (seconds), +max_batch_size+, +max_queue_size+.
|
|
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
|
+
version: 0.5.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
|