mcpspan 0.1.0 → 0.3.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 +3 -2
- data/lib/mcpspan/collector.rb +21 -2
- data/lib/mcpspan/definitions.rb +82 -0
- data/lib/mcpspan/event.rb +4 -1
- data/lib/mcpspan/instrumentation.rb +32 -4
- data/lib/mcpspan/primitives.rb +1 -1
- data/lib/mcpspan/version.rb +1 -1
- data/lib/mcpspan.rb +1 -0
- 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: 8d0a4e3f16e81ed07733683b6bd1984d05955ba6cd3baff32dfeae0855dd3a52
|
|
4
|
+
data.tar.gz: 5b3a34b8f738da547c0d6cebbfdcb4debf6622eaf92f5c6bfb3ba6c68438dd5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b93798f1e34fec652bb47d9a184d22df8a1cf6ee64b3ef774203572b6ffe9d314e511f164bb175e6f50cee52602415aa7740c46cb74525646acfbb9b34dfa1d
|
|
7
|
+
data.tar.gz: 8847e3f04f30ba0e3c2f2aafb693851084f963c308e3cac81c665f816f3399e591d893a0d0716e0412cfb2d77b3210902ba24dc2cc658eff8062967dbbde4863
|
data/README.md
CHANGED
|
@@ -148,8 +148,9 @@ read from the server's wording.
|
|
|
148
148
|
mode, not in debug.
|
|
149
149
|
|
|
150
150
|
What is collected: the tool name, how long it took, whether it succeeded, the
|
|
151
|
-
error type and a truncated message when it did not,
|
|
152
|
-
|
|
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
|
|
153
154
|
registered with: never the address a client read, only its template or, for
|
|
154
155
|
an address the server does not have, its scheme.
|
|
155
156
|
|
data/lib/mcpspan/collector.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module McpSpan
|
|
@@ -103,8 +104,23 @@ module McpSpan
|
|
|
103
104
|
)
|
|
104
105
|
end
|
|
105
106
|
|
|
106
|
-
#
|
|
107
|
-
|
|
107
|
+
# The largest size an event carries; anything larger is sent as this (contract, 3.7).
|
|
108
|
+
MAX_RESPONSE_BYTES = 2_147_483_647
|
|
109
|
+
|
|
110
|
+
# Size of an answer in bytes of its compact JSON (contract, 3.7), or nil when there is none or it cannot be
|
|
111
|
+
# encoded. The JSON is counted and dropped; nothing of it is kept or sent.
|
|
112
|
+
def response_bytes(response)
|
|
113
|
+
return nil if response.nil?
|
|
114
|
+
|
|
115
|
+
value = response.is_a?(Hash) || response.is_a?(Array) || response.is_a?(String) ? response : response.to_h
|
|
116
|
+
[JSON.generate(value).bytesize, MAX_RESPONSE_BYTES].min
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Builds the event for a finished call and queues it. It never blocks on the network. `response` is the answer,
|
|
122
|
+
# when there was one, to be measured.
|
|
123
|
+
def record(call, success:, source: nil, type: nil, message: nil, response: nil)
|
|
108
124
|
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - call.started) * 1000.0
|
|
109
125
|
reporter = @reporter
|
|
110
126
|
return if reporter.nil?
|
|
@@ -122,6 +138,9 @@ module McpSpan
|
|
|
122
138
|
client_name: Text.client_name(call.client_name),
|
|
123
139
|
client_version: Text.version(call.client_version),
|
|
124
140
|
server_version: Text.version(call.server_version),
|
|
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,
|
|
125
144
|
timestamp: call.timestamp.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
|
|
126
145
|
session_id: call.session_id,
|
|
127
146
|
parameters: call.parameters,
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
private
|
|
52
|
+
|
|
53
|
+
# Sorted keys, no whitespace, minimal escaping: the same text in every SDK.
|
|
54
|
+
def canonical(value)
|
|
55
|
+
case value
|
|
56
|
+
when nil then "null"
|
|
57
|
+
when true then "true"
|
|
58
|
+
when false then "false"
|
|
59
|
+
when Integer then value.to_s
|
|
60
|
+
when Float
|
|
61
|
+
raise ArgumentError, "not a JSON number" unless value.finite?
|
|
62
|
+
|
|
63
|
+
value == value.floor && value.abs < 1e15 ? value.to_i.to_s : value.to_s
|
|
64
|
+
when String then text(value)
|
|
65
|
+
when Array then "[#{value.map { |item| canonical(item) }.join(",")}]"
|
|
66
|
+
when Hash
|
|
67
|
+
pairs = value.map { |key, item| [key.to_s, item] }.sort_by(&:first)
|
|
68
|
+
"{#{pairs.map { |key, item| "#{text(key)}:#{canonical(item)}" }.join(",")}}"
|
|
69
|
+
else raise ArgumentError, "cannot fingerprint #{value.class}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def text(value)
|
|
74
|
+
out = +'"'
|
|
75
|
+
value.each_char do |character|
|
|
76
|
+
out << (ESCAPES[character] || (character.ord < 0x20 ? format("\\u%04x", character.ord) : character))
|
|
77
|
+
end
|
|
78
|
+
out << '"'
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/mcpspan/event.rb
CHANGED
|
@@ -6,7 +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, :
|
|
9
|
+
:client_type, :client_name, :client_version, :server_version, :response_bytes, :definition_hash, :timestamp,
|
|
10
|
+
:session_id, :parameters,
|
|
10
11
|
keyword_init: true,
|
|
11
12
|
) do
|
|
12
13
|
# The event as the API takes it, leaving absent fields out rather than sending them as null.
|
|
@@ -24,6 +25,8 @@ module McpSpan
|
|
|
24
25
|
clientName: client_name,
|
|
25
26
|
clientVersion: client_version,
|
|
26
27
|
serverVersion: server_version,
|
|
28
|
+
responseBytes: response_bytes,
|
|
29
|
+
definitionHash: definition_hash,
|
|
27
30
|
timestamp: timestamp,
|
|
28
31
|
sdkVersion: VERSION,
|
|
29
32
|
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
|
|
@@ -172,11 +198,12 @@ module McpSpan
|
|
|
172
198
|
def __mcpspan_settled(call, state, result)
|
|
173
199
|
# An interim result asking the client for input settles nothing; the call that follows it does.
|
|
174
200
|
return if Instrumentation.interim?(result)
|
|
175
|
-
return Collector.record(call, success: true) unless result.is_a?(Hash) && result[:isError]
|
|
201
|
+
return Collector.record(call, success: true, response: result) unless result.is_a?(Hash) && result[:isError]
|
|
176
202
|
|
|
177
203
|
# The gem refuses missing or invalid arguments with an error result, before the tool is called.
|
|
178
204
|
if state.reached
|
|
179
|
-
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(result)
|
|
205
|
+
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(result),
|
|
206
|
+
response: result,)
|
|
180
207
|
else
|
|
181
208
|
Collector.record(call, success: false, source: Source::ARGUMENTS)
|
|
182
209
|
end
|
|
@@ -204,9 +231,10 @@ module McpSpan
|
|
|
204
231
|
if call
|
|
205
232
|
hash = result.respond_to?(:to_h) ? result.to_h : {}
|
|
206
233
|
if hash[:isError]
|
|
207
|
-
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(hash)
|
|
234
|
+
Collector.record(call, success: false, source: Source::RESULT, message: Instrumentation.result_text(hash),
|
|
235
|
+
response: hash,)
|
|
208
236
|
elsif !Instrumentation.interim?(result)
|
|
209
|
-
Collector.record(call, success: true)
|
|
237
|
+
Collector.record(call, success: true, response: hash)
|
|
210
238
|
end
|
|
211
239
|
end
|
|
212
240
|
result
|
data/lib/mcpspan/primitives.rb
CHANGED
|
@@ -141,7 +141,7 @@ module McpSpan
|
|
|
141
141
|
|
|
142
142
|
def __mcpspan_succeeded(call, result)
|
|
143
143
|
# An interim result asking the client for input settles nothing; the request that follows it does.
|
|
144
|
-
Collector.record(call, success: true) unless Instrumentation.interim?(result)
|
|
144
|
+
Collector.record(call, success: true, response: result) unless Instrumentation.interim?(result)
|
|
145
145
|
rescue *Instrumentation::INTERNAL
|
|
146
146
|
nil
|
|
147
147
|
end
|
data/lib/mcpspan/version.rb
CHANGED
data/lib/mcpspan.rb
CHANGED
|
@@ -6,6 +6,7 @@ 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"
|
|
9
10
|
require_relative "mcpspan/instrumentation"
|
|
10
11
|
require_relative "mcpspan/primitives"
|
|
11
12
|
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kacper Zatoń
|
|
@@ -20,6 +20,7 @@ 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
|